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::codegen::GenericBindings::CookieStoreBinding::CookieSameSite;
18use script_bindings::script_runtime::CanGc;
19use servo_base::generic_channel::{GenericCallback, GenericSend, GenericSender};
20use servo_base::id::CookieStoreId;
21use servo_url::ServoUrl;
22use time::OffsetDateTime;
23
24use crate::dom::bindings::cell::DomRefCell;
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, reflect_dom_object};
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 || {
82            let Some(promise) = context.root().in_flight.borrow_mut().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(&cookie_to_list_item(cookie.into_inner()), CanGc::note());
93                    } else {
94                        // If list is empty, then resolve p with null.
95                        promise.resolve_native(&NullValue(), CanGc::note());
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(
101                        &cookies
102                        .into_iter()
103                        .map(|cookie| cookie_to_list_item(cookie.0))
104                        .collect_vec(),
105                    CanGc::note());
106                },
107                CookieData::Delete(_) | CookieData::Change(_) | CookieData::Set(_) => {
108                    promise.resolve_native(&(), CanGc::note());
109                }
110            }
111        }));
112    }
113}
114
115impl CookieStore {
116    fn new_inherited(unregister_channel: GenericSender<CoreResourceMsg>) -> CookieStore {
117        CookieStore {
118            eventtarget: EventTarget::new_inherited(),
119            in_flight: Default::default(),
120            droppable: DroppableCookieStore {
121                store_id: CookieStoreId::new(),
122                unregister_channel,
123            },
124        }
125    }
126
127    pub(crate) fn new(global: &GlobalScope, can_gc: CanGc) -> DomRoot<CookieStore> {
128        let store = reflect_dom_object(
129            Box::new(CookieStore::new_inherited(
130                global.resource_threads().core_thread.clone(),
131            )),
132            global,
133            can_gc,
134        );
135        store.setup_route();
136        store
137    }
138
139    fn setup_route(&self) {
140        let context = Trusted::new(self);
141        let cs_listener = CookieListener {
142            task_source: self
143                .global()
144                .task_manager()
145                .dom_manipulation_task_source()
146                .to_sendable(),
147            context,
148        };
149
150        let callback = GenericCallback::new(move |message| match message {
151            Ok(msg) => cs_listener.handle(msg),
152            Err(err) => warn!("Error receiving a CookieStore message: {:?}", err),
153        })
154        .expect("Could not create cookie store callback");
155
156        let res = self
157            .global()
158            .resource_threads()
159            .send(CoreResourceMsg::NewCookieListener(
160                self.droppable.store_id,
161                callback,
162                self.global().creation_url(),
163            ));
164        if res.is_err() {
165            error!("Failed to send cookiestore message to resource threads");
166        }
167    }
168}
169
170/// <https://cookiestore.spec.whatwg.org/#create-a-cookielistitem>
171fn cookie_to_list_item(cookie: Cookie) -> CookieListItem {
172    // TODO: Investigate if we need to explicitly UTF-8 decode without BOM here or if thats
173    // already being done by cookie-rs or implicitly by using rust strings
174    CookieListItem {
175        // Let name be the result of running UTF-8 decode without BOM on cookie’s name.
176        name: Some(cookie.name().to_string().into()),
177
178        // Let value be the result of running UTF-8 decode without BOM on cookie’s value.
179        value: Some(cookie.value().to_string().into()),
180    }
181}
182
183impl CookieStoreMethods<crate::DomTypeHolder> for CookieStore {
184    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-get>
185    fn Get(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
186        // 1. Let settings be this’s relevant settings object.
187        let global = self.global();
188
189        // 2. Let origin be settings’s origin.
190        let origin = global.origin();
191
192        // 5. Let p be a new promise.
193        let p = Promise::new(&global, CanGc::from_cx(cx));
194
195        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
196        if !origin.is_tuple() {
197            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
198            return p;
199        }
200
201        // 4. Let url be settings’s creation URL.
202        let creation_url = global.creation_url();
203
204        let name = CookieStore::normalize(&name);
205
206        // 6. Run the following steps in parallel:
207        let res = self
208            .global()
209            .resource_threads()
210            .send(CoreResourceMsg::GetCookieDataForUrlAsync(
211                self.droppable.store_id,
212                creation_url,
213                Some(name),
214            ));
215        if res.is_err() {
216            error!("Failed to send cookiestore message to resource threads");
217        } else {
218            self.in_flight.borrow_mut().push_back(p.clone());
219        }
220
221        // 7. Return p.
222        p
223    }
224
225    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-get-options>
226    fn Get_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
227        // 1. Let settings be this’s relevant settings object.
228        let global = self.global();
229
230        // 2. Let origin be settings’s origin.
231        let origin = global.origin();
232
233        // 7. Let p be a new promise.
234        let p = Promise::new(&global, CanGc::from_cx(cx));
235
236        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
237        if !origin.is_tuple() {
238            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
239            return p;
240        }
241
242        // 4. Let url be settings’s creation URL.
243        let creation_url = global.creation_url();
244
245        // 5. If options is empty, then return a promise rejected with a TypeError.
246        // "is empty" is not strictly defined anywhere in the spec but the only value we require here is "url"
247        if options.url.is_none() && options.name.is_none() {
248            p.reject_error(
249                Error::Type(c"Options cannot be empty".to_owned()),
250                CanGc::from_cx(cx),
251            );
252            return p;
253        }
254
255        let mut final_url = creation_url.clone();
256
257        // 6. If options["url"] is present, then run these steps:
258        if let Some(get_url) = &options.url {
259            // 6.1. Let parsed be the result of parsing options["url"] with settings’s API base URL.
260            let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
261
262            // 6.2. If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments set to true,
263            // then return a promise rejected with a TypeError.
264            if let Some(_window) = DomRoot::downcast::<Window>(self.global()) {
265                if parsed_url
266                    .as_ref()
267                    .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
268                {
269                    p.reject_error(
270                        Error::Type(c"URL does not match context".to_owned()),
271                        CanGc::from_cx(cx),
272                    );
273                    return p;
274                }
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                if 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
397            // 5.3. If parsed’s origin and url’s origin are not the same origin,
398            // then return a promise rejected with a TypeError.
399            if parsed_url
400                .as_ref()
401                .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
402            {
403                p.reject_error(
404                    Error::Type(c"Not same origin".to_owned()),
405                    CanGc::from_cx(cx),
406                );
407                return p;
408            }
409
410            // 5.4. Set url to parsed.
411            if let Ok(url) = parsed_url {
412                final_url = url;
413            }
414        }
415
416        // 7. Run the following steps in parallel:
417        let res =
418            self.global()
419                .resource_threads()
420                .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
421                    self.droppable.store_id,
422                    final_url,
423                    options.name.clone().map(|val| CookieStore::normalize(&val)),
424                ));
425        if res.is_err() {
426            error!("Failed to send cookiestore message to resource threads");
427        } else {
428            self.in_flight.borrow_mut().push_back(p.clone());
429        }
430
431        // 8. Return p
432        p
433    }
434
435    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set>
436    fn Set(&self, cx: &mut JSContext, name: USVString, value: USVString) -> Rc<Promise> {
437        // 1. Let settings be this’s relevant settings object.
438        let global = self.global();
439
440        // 2. Let origin be settings’s origin.
441        let origin = global.origin();
442
443        // 9. Let p be a new promise.
444        let p = Promise::new(&global, CanGc::from_cx(cx));
445
446        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
447        if !origin.is_tuple() {
448            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
449            return p;
450        }
451
452        // 6.1 Let r be the result of running set a cookie with url, name, value, null, null, "/", "strict", false, and null.
453        let properties = CookieInit {
454            name,
455            value,
456            expires: None,
457            domain: None,
458            path: USVString(String::from("/")),
459            sameSite: CookieSameSite::Strict,
460            partitioned: false,
461        };
462        let creation_url = global.creation_url();
463        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, &properties) else {
464            // If r is failure, then reject p with a TypeError and abort these steps.
465            p.reject_error(
466                Error::Type(c"Invalid cookie".to_owned()),
467                CanGc::from_cx(cx),
468            );
469            return p;
470        };
471
472        // 6. Run the following steps in parallel:
473        let res = self
474            .global()
475            .resource_threads()
476            .send(CoreResourceMsg::SetCookieForUrlAsync(
477                self.droppable.store_id,
478                creation_url.clone(),
479                Serde(cookie.into_owned()),
480                NonHTTP,
481            ));
482        if res.is_err() {
483            error!("Failed to send cookiestore message to resource threads");
484        } else {
485            self.in_flight.borrow_mut().push_back(p.clone());
486        }
487
488        // 7. Return p.
489        p
490    }
491
492    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set-options>
493    fn Set_(&self, cx: &mut JSContext, options: &CookieInit) -> Rc<Promise> {
494        // 1. Let settings be this’s relevant settings object.
495        let global = self.global();
496
497        // 2. Let origin be settings’s origin.
498        let origin = global.origin();
499
500        // 5. Let p be a new promise.
501        let p = Promise::new(&global, CanGc::from_cx(cx));
502
503        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
504        if !origin.is_tuple() {
505            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
506            return p;
507        }
508
509        // 4. Let url be settings’s creation URL.
510        let creation_url = global.creation_url();
511
512        // 6.1. Let r be the result of running set a cookie with url, options["name"], options["value"],
513        // options["expires"], options["domain"], options["path"], options["sameSite"], and options["partitioned"].
514        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, options) else {
515            p.reject_error(
516                Error::Type(c"Invalid cookie".to_owned()),
517                CanGc::from_cx(cx),
518            );
519            return p;
520        };
521
522        // 6. Run the following steps in parallel:
523        let res = self
524            .global()
525            .resource_threads()
526            .send(CoreResourceMsg::SetCookieForUrlAsync(
527                self.droppable.store_id,
528                creation_url.clone(),
529                Serde(cookie.into_owned()),
530                NonHTTP,
531            ));
532        if res.is_err() {
533            error!("Failed to send cookiestore message to resource threads");
534        } else {
535            self.in_flight.borrow_mut().push_back(p.clone());
536        }
537
538        // 7. Return p
539        p
540    }
541
542    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete>
543    fn Delete(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
544        // 1. Let settings be this’s relevant settings object.
545        let global = self.global();
546
547        // 2. Let origin be settings’s origin.
548        let origin = global.origin();
549
550        // 5. Let p be a new promise.
551        let p = Promise::new(&global, CanGc::from_cx(cx));
552
553        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
554        if !origin.is_tuple() {
555            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
556            return p;
557        }
558
559        // 6. Run the following steps in parallel:
560        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
561        let res = global
562            .resource_threads()
563            .send(CoreResourceMsg::DeleteCookieAsync(
564                self.droppable.store_id,
565                global.creation_url(),
566                name.0,
567            ));
568        if res.is_err() {
569            error!("Failed to send cookiestore message to resource threads");
570        } else {
571            self.in_flight.borrow_mut().push_back(p.clone());
572        }
573
574        // 7. Return p.
575        p
576    }
577
578    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete-options>
579    fn Delete_(&self, cx: &mut JSContext, options: &CookieStoreDeleteOptions) -> Rc<Promise> {
580        // 1. Let settings be this’s relevant settings object.
581        let global = self.global();
582
583        // 2. Let origin be settings’s origin.
584        let origin = global.origin();
585
586        // 5. Let p be a new promise.
587        let p = Promise::new(&global, CanGc::from_cx(cx));
588
589        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
590        if !origin.is_tuple() {
591            p.reject_error(Error::Security(None), CanGc::from_cx(cx));
592            return p;
593        }
594
595        // 6. Run the following steps in parallel:
596        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
597        let res = global
598            .resource_threads()
599            .send(CoreResourceMsg::DeleteCookieAsync(
600                self.droppable.store_id,
601                global.creation_url(),
602                options.name.to_string(),
603            ));
604        if res.is_err() {
605            error!("Failed to send cookiestore message to resource threads");
606        } else {
607            self.in_flight.borrow_mut().push_back(p.clone());
608        }
609
610        // 7. Return p.
611        p
612    }
613}
614
615impl CookieStore {
616    /// <https://cookiestore.spec.whatwg.org/#normalize-a-cookie-name-or-value>
617    fn normalize(value: &USVString) -> String {
618        value.trim_matches([' ', '\t']).into()
619    }
620
621    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
622    fn set_a_cookie<'a>(url: &'a ServoUrl, properties: &'a CookieInit) -> Option<Cookie<'a>> {
623        // 1. Normalize name.
624        let name = CookieStore::normalize(&properties.name);
625        // 2. Normalize value.
626        let value = CookieStore::normalize(&properties.value);
627
628        // 3. If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
629        if CookieStore::contains_control_characters(&name) ||
630            CookieStore::contains_control_characters(&value)
631        {
632            return None;
633        }
634
635        // 4. If name contains U+003D (=), then return failure.
636        if name.contains('=') {
637            return None;
638        }
639
640        // 5. If name’s length is 0:
641        if name.is_empty() {
642            // 5.1 If value contains U+003D (=), then return failure.
643            // 5.2 If value’s length is 0, then return failure.
644            if value.contains('=') || value.is_empty() {
645                return None;
646            }
647            // 5.3 If value, byte-lowercased, starts with `__host-`, `__host-http-`, `__http-`, or `__secure-`, then return failure.
648            let lowercased_value = value.to_ascii_lowercase();
649            if ["__host-", "__host-http-", "__http-", "__secure-"]
650                .iter()
651                .any(|prefix| lowercased_value.starts_with(prefix))
652            {
653                return None;
654            }
655        }
656
657        // 6. If name, byte-lowercased, starts with `__host-http-` or `__http-`, then return failure.
658        let lowercased_name = name.to_ascii_lowercase();
659        if lowercased_name.starts_with("__host-http-") || lowercased_name.starts_with("__http-") {
660            return None;
661        }
662
663        // 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.
664        if name.len() + value.len() > 4096 {
665            return None;
666        }
667
668        let mut cookie = Cookie::build((Cow::Owned(name), Cow::Owned(value)))
669            // 21. Append ('Secure', '') to attributes
670            .secure(true)
671            // 23. If partitioned is true, Append (`Partitioned`, ``) to attributes.
672            .partitioned(properties.partitioned)
673            // 21. Switch on sameSite:
674            .same_site(match properties.sameSite {
675                CookieSameSite::Lax => SameSite::Lax,
676                CookieSameSite::Strict => SameSite::Strict,
677                CookieSameSite::None => SameSite::None,
678            });
679
680        // 12. If domain is non-null
681        if let Some(domain) = &properties.domain {
682            // 10. Let host be url's host.
683            let host = match url.host() {
684                Some(host) => host.to_owned(),
685                None => return None,
686            };
687            // 12.1 If domain starts with U+002E (.), then return failure
688            if domain.starts_with('.') {
689                return None;
690            }
691            // 12.2 If name, byte-lowercased, starts with `__host-`, then return failure.
692            if lowercased_name.starts_with("__host-") {
693                return None;
694            }
695
696            // 12.3 If domain is not a registrable domain suffix of and is not equal to host, then return failure.
697            // 12.4 Let parsedDomain be the result of host parsing domain.
698            // 12.5 Assert: parsedDomain is not failure.
699            // Note: this function parses the host and returns the parsed host on success
700            let domain = get_registrable_domain_suffix_of_or_is_equal_to(domain, host)?;
701
702            // 12.6 Let encodedDomain be the result of UTF-8 encoding parsedDomain.
703            let domain = domain.to_string();
704            // 12.7 If the byte sequence length of encodedDomain is greater than the maximum attribute value size, then return failure.
705            if domain.len() > 1024 {
706                return None;
707            }
708            // 12.8 Append (`Domain`, encodedDomain) to attributes.
709            cookie.inner_mut().set_domain(domain);
710        }
711
712        // 13. If expires is non-null:
713        if let Some(expiry) = properties.expires {
714            // TODO: update cookiestore to take new maxAge parameter
715            // 13.2 Append (`Expires`, expires (date serialized)) to attributes.
716            cookie.inner_mut().set_expires(
717                OffsetDateTime::from_unix_timestamp((*expiry / 1000.0) as i64)
718                    .expect("cookie expiry out of range"),
719            );
720        }
721
722        // 15. If path is the empty string, then set path to the serialized cookie default path of url.
723        let path = if properties.path.is_empty() {
724            // 15.1 Let cloneURL be a clone of url.
725            let mut cloned_url = url.clone();
726            {
727                // 15.2 Set cloneURL’s path to the cookie default path of cloneURL’s path.
728                // <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-layered-cookies#name-cookie-default-path>
729                let mut path_segments = cloned_url
730                    .as_mut_url()
731                    .path_segments_mut()
732                    .expect("document creation url cannot be a base");
733                // 2. If path's size is greater than 1, then remove path's last item
734                if url.path_segments().is_some_and(|ps| ps.count() > 1) {
735                    path_segments.pop();
736                } else {
737                    // 3. Otherwise, set path[0] to the empty string.
738                    path_segments.clear();
739                }
740            }
741            cloned_url.path().to_owned()
742        } else {
743            properties.path.to_string()
744        };
745        // 16. If path does not start with U+002F (/), then return failure.
746        if !path.starts_with('/') {
747            return None;
748        }
749        // 17. If path is not U+002F (/), and name, byte-lowercased, starts with `__host-`, then return failure.
750        if path != "/" && lowercased_name.starts_with("__host-") {
751            return None;
752        }
753        // 19. If the byte sequence length of encodedPath is greater than the maximum attribute value size, then return failure.
754        if path.len() > 1024 {
755            return None;
756        }
757        // 20. Append (`Path`, encodedPath) to attributes.
758        cookie.inner_mut().set_path(path);
759
760        Some(cookie.build())
761    }
762
763    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
764    fn contains_control_characters(val: &str) -> bool {
765        // If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
766        val.contains(
767            |v| matches!(v, '\u{0000}'..='\u{0008}' | '\u{000a}'..='\u{001f}' | '\u{007f}' | ';'),
768        )
769    }
770}