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::new2(cx, &global);
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_with_cx(cx, Error::Security(None));
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::new2(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_with_cx(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_with_cx(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_with_cx(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_with_cx(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.borrow_mut().push_back(p.clone());
300        }
301
302        p
303    }
304
305    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall>
306    fn GetAll(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
307        // 1. Let settings be this’s relevant settings object.
308        let global = self.global();
309
310        // 2. Let origin be settings’s origin.
311        let origin = global.origin();
312
313        // 5. Let p be a new promise.
314        let p = Promise::new2(cx, &global);
315
316        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
317        if !origin.is_tuple() {
318            p.reject_error_with_cx(cx, Error::Security(None));
319            return p;
320        }
321        // 4. Let url be settings’s creation URL.
322        let creation_url = global.creation_url();
323
324        // Normalize name here rather than passing the un-nomarlized name around to the resource thread and back
325        let name = CookieStore::normalize(&name);
326
327        // 6. Run the following steps in parallel:
328        let res =
329            self.global()
330                .resource_threads()
331                .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
332                    self.droppable.store_id,
333                    creation_url,
334                    Some(name),
335                ));
336        if res.is_err() {
337            error!("Failed to send cookiestore message to resource threads");
338        } else {
339            self.in_flight.borrow_mut().push_back(p.clone());
340        }
341
342        // 7. Return p.
343        p
344    }
345
346    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-getall-options>
347    fn GetAll_(&self, cx: &mut JSContext, options: &CookieStoreGetOptions) -> Rc<Promise> {
348        // 1. Let settings be this’s relevant settings object.
349        let global = self.global();
350
351        // 2. Let origin be settings’s origin.
352        let origin = global.origin();
353
354        // 6. Let p be a new promise.
355        let p = Promise::new2(cx, &global);
356
357        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
358        if !origin.is_tuple() {
359            p.reject_error_with_cx(cx, Error::Security(None));
360            return p;
361        }
362
363        // 4. Let url be settings’s creation URL.
364        let creation_url = global.creation_url();
365
366        let mut final_url = creation_url.clone();
367
368        // 5. If options["url"] is present, then run these steps:
369        if let Some(get_url) = &options.url {
370            // 5.1. Let parsed be the result of parsing options["url"] with settings’s API base URL.
371            let parsed_url = ServoUrl::parse_with_base(Some(&global.api_base_url()), get_url);
372
373            // If this’s relevant global object is a Window object and parsed does not equal url with exclude fragments set to true,
374            // then return a promise rejected with a TypeError.
375            if let Some(_window) = DomRoot::downcast::<Window>(self.global()) &&
376                parsed_url
377                    .as_ref()
378                    .is_ok_and(|parsed| !parsed.is_equal_excluding_fragments(&creation_url))
379            {
380                p.reject_error_with_cx(cx, Error::Type(c"URL does not match context".to_owned()));
381                return p;
382            }
383
384            // 5.3. If parsed’s origin and url’s origin are not the same origin,
385            // then return a promise rejected with a TypeError.
386            if parsed_url
387                .as_ref()
388                .is_ok_and(|parsed| creation_url.origin() != parsed.origin())
389            {
390                p.reject_error_with_cx(cx, Error::Type(c"Not same origin".to_owned()));
391                return p;
392            }
393
394            // 5.4. Set url to parsed.
395            if let Ok(url) = parsed_url {
396                final_url = url;
397            }
398        }
399
400        // 7. Run the following steps in parallel:
401        let res =
402            self.global()
403                .resource_threads()
404                .send(CoreResourceMsg::GetAllCookieDataForUrlAsync(
405                    self.droppable.store_id,
406                    final_url,
407                    options.name.clone().map(|val| CookieStore::normalize(&val)),
408                ));
409        if res.is_err() {
410            error!("Failed to send cookiestore message to resource threads");
411        } else {
412            self.in_flight.borrow_mut().push_back(p.clone());
413        }
414
415        // 8. Return p
416        p
417    }
418
419    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set>
420    fn Set(&self, cx: &mut JSContext, name: USVString, value: USVString) -> Rc<Promise> {
421        // 1. Let settings be this’s relevant settings object.
422        let global = self.global();
423
424        // 2. Let origin be settings’s origin.
425        let origin = global.origin();
426
427        // 9. Let p be a new promise.
428        let p = Promise::new2(cx, &global);
429
430        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
431        if !origin.is_tuple() {
432            p.reject_error_with_cx(cx, Error::Security(None));
433            return p;
434        }
435
436        // 6.1 Let r be the result of running set a cookie with url, name, value, null, null, "/", "strict", false, and null.
437        let properties = CookieInit {
438            name,
439            value,
440            expires: None,
441            domain: None,
442            path: USVString(String::from("/")),
443            sameSite: CookieSameSite::Strict,
444            partitioned: false,
445        };
446        let creation_url = global.creation_url();
447        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, &properties) else {
448            // If r is failure, then reject p with a TypeError and abort these steps.
449            p.reject_error_with_cx(cx, Error::Type(c"Invalid cookie".to_owned()));
450            return p;
451        };
452
453        // 6. Run the following steps in parallel:
454        let res = self
455            .global()
456            .resource_threads()
457            .send(CoreResourceMsg::SetCookieForUrlAsync(
458                self.droppable.store_id,
459                creation_url.clone(),
460                Serde(cookie.into_owned()),
461                NonHTTP,
462            ));
463        if res.is_err() {
464            error!("Failed to send cookiestore message to resource threads");
465        } else {
466            self.in_flight.borrow_mut().push_back(p.clone());
467        }
468
469        // 7. Return p.
470        p
471    }
472
473    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-set-options>
474    fn Set_(&self, cx: &mut JSContext, options: &CookieInit) -> Rc<Promise> {
475        // 1. Let settings be this’s relevant settings object.
476        let global = self.global();
477
478        // 2. Let origin be settings’s origin.
479        let origin = global.origin();
480
481        // 5. Let p be a new promise.
482        let p = Promise::new2(cx, &global);
483
484        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
485        if !origin.is_tuple() {
486            p.reject_error_with_cx(cx, Error::Security(None));
487            return p;
488        }
489
490        // 4. Let url be settings’s creation URL.
491        let creation_url = global.creation_url();
492
493        // 6.1. Let r be the result of running set a cookie with url, options["name"], options["value"],
494        // options["expires"], options["domain"], options["path"], options["sameSite"], and options["partitioned"].
495        let Some(cookie) = CookieStore::set_a_cookie(&creation_url, options) else {
496            p.reject_error_with_cx(cx, Error::Type(c"Invalid cookie".to_owned()));
497            return p;
498        };
499
500        // 6. Run the following steps in parallel:
501        let res = self
502            .global()
503            .resource_threads()
504            .send(CoreResourceMsg::SetCookieForUrlAsync(
505                self.droppable.store_id,
506                creation_url.clone(),
507                Serde(cookie.into_owned()),
508                NonHTTP,
509            ));
510        if res.is_err() {
511            error!("Failed to send cookiestore message to resource threads");
512        } else {
513            self.in_flight.borrow_mut().push_back(p.clone());
514        }
515
516        // 7. Return p
517        p
518    }
519
520    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete>
521    fn Delete(&self, cx: &mut JSContext, name: USVString) -> Rc<Promise> {
522        // 1. Let settings be this’s relevant settings object.
523        let global = self.global();
524
525        // 2. Let origin be settings’s origin.
526        let origin = global.origin();
527
528        // 5. Let p be a new promise.
529        let p = Promise::new2(cx, &global);
530
531        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
532        if !origin.is_tuple() {
533            p.reject_error_with_cx(cx, Error::Security(None));
534            return p;
535        }
536
537        // 6. Run the following steps in parallel:
538        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
539        let res = global
540            .resource_threads()
541            .send(CoreResourceMsg::DeleteCookieAsync(
542                self.droppable.store_id,
543                global.creation_url(),
544                name.0,
545            ));
546        if res.is_err() {
547            error!("Failed to send cookiestore message to resource threads");
548        } else {
549            self.in_flight.borrow_mut().push_back(p.clone());
550        }
551
552        // 7. Return p.
553        p
554    }
555
556    /// <https://cookiestore.spec.whatwg.org/#dom-cookiestore-delete-options>
557    fn Delete_(&self, cx: &mut JSContext, options: &CookieStoreDeleteOptions) -> Rc<Promise> {
558        // 1. Let settings be this’s relevant settings object.
559        let global = self.global();
560
561        // 2. Let origin be settings’s origin.
562        let origin = global.origin();
563
564        // 5. Let p be a new promise.
565        let p = Promise::new2(cx, &global);
566
567        // 3. If origin is an opaque origin, then return a promise rejected with a "SecurityError" DOMException.
568        if !origin.is_tuple() {
569            p.reject_error_with_cx(cx, Error::Security(None));
570            return p;
571        }
572
573        // 6. Run the following steps in parallel:
574        // TODO: the spec passes additional parameters to _delete a cookie_ that we don't handle yet
575        let res = global
576            .resource_threads()
577            .send(CoreResourceMsg::DeleteCookieAsync(
578                self.droppable.store_id,
579                global.creation_url(),
580                options.name.to_string(),
581            ));
582        if res.is_err() {
583            error!("Failed to send cookiestore message to resource threads");
584        } else {
585            self.in_flight.borrow_mut().push_back(p.clone());
586        }
587
588        // 7. Return p.
589        p
590    }
591}
592
593impl CookieStore {
594    /// <https://cookiestore.spec.whatwg.org/#normalize-a-cookie-name-or-value>
595    fn normalize(value: &USVString) -> String {
596        value.trim_matches([' ', '\t']).into()
597    }
598
599    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
600    fn set_a_cookie<'a>(url: &'a ServoUrl, properties: &'a CookieInit) -> Option<Cookie<'a>> {
601        // 1. Normalize name.
602        let name = CookieStore::normalize(&properties.name);
603        // 2. Normalize value.
604        let value = CookieStore::normalize(&properties.value);
605
606        // 3. If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
607        if CookieStore::contains_control_characters(&name) ||
608            CookieStore::contains_control_characters(&value)
609        {
610            return None;
611        }
612
613        // 4. If name contains U+003D (=), then return failure.
614        if name.contains('=') {
615            return None;
616        }
617
618        // 5. If name’s length is 0:
619        if name.is_empty() {
620            // 5.1 If value contains U+003D (=), then return failure.
621            // 5.2 If value’s length is 0, then return failure.
622            if value.contains('=') || value.is_empty() {
623                return None;
624            }
625            // 5.3 If value, byte-lowercased, starts with `__host-`, `__host-http-`, `__http-`, or `__secure-`, then return failure.
626            let lowercased_value = value.to_ascii_lowercase();
627            if ["__host-", "__host-http-", "__http-", "__secure-"]
628                .iter()
629                .any(|prefix| lowercased_value.starts_with(prefix))
630            {
631                return None;
632            }
633        }
634
635        // 6. If name, byte-lowercased, starts with `__host-http-` or `__http-`, then return failure.
636        let lowercased_name = name.to_ascii_lowercase();
637        if lowercased_name.starts_with("__host-http-") || lowercased_name.starts_with("__http-") {
638            return None;
639        }
640
641        // 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.
642        if name.len() + value.len() > 4096 {
643            return None;
644        }
645
646        let mut cookie = Cookie::build((Cow::Owned(name), Cow::Owned(value)))
647            // 21. Append ('Secure', '') to attributes
648            .secure(true)
649            // 23. If partitioned is true, Append (`Partitioned`, ``) to attributes.
650            .partitioned(properties.partitioned)
651            // 21. Switch on sameSite:
652            .same_site(match properties.sameSite {
653                CookieSameSite::Lax => SameSite::Lax,
654                CookieSameSite::Strict => SameSite::Strict,
655                CookieSameSite::None => SameSite::None,
656            });
657
658        // 12. If domain is non-null
659        if let Some(domain) = &properties.domain {
660            // 10. Let host be url's host.
661            let host = match url.host() {
662                Some(host) => host.to_owned(),
663                None => return None,
664            };
665            // 12.1 If domain starts with U+002E (.), then return failure
666            if domain.starts_with('.') {
667                return None;
668            }
669            // 12.2 If name, byte-lowercased, starts with `__host-`, then return failure.
670            if lowercased_name.starts_with("__host-") {
671                return None;
672            }
673
674            // 12.3 If domain is not a registrable domain suffix of and is not equal to host, then return failure.
675            // 12.4 Let parsedDomain be the result of host parsing domain.
676            // 12.5 Assert: parsedDomain is not failure.
677            // Note: this function parses the host and returns the parsed host on success
678            let domain = get_registrable_domain_suffix_of_or_is_equal_to(domain, host)?;
679
680            // 12.6 Let encodedDomain be the result of UTF-8 encoding parsedDomain.
681            let domain = domain.to_string();
682            // 12.7 If the byte sequence length of encodedDomain is greater than the maximum attribute value size, then return failure.
683            if domain.len() > 1024 {
684                return None;
685            }
686            // 12.8 Append (`Domain`, encodedDomain) to attributes.
687            cookie.inner_mut().set_domain(domain);
688        }
689
690        // 13. If expires is non-null:
691        if let Some(expiry) = properties.expires {
692            // TODO: update cookiestore to take new maxAge parameter
693            // 13.2 Append (`Expires`, expires (date serialized)) to attributes.
694            cookie.inner_mut().set_expires(
695                OffsetDateTime::from_unix_timestamp((*expiry / 1000.0) as i64)
696                    .expect("cookie expiry out of range"),
697            );
698        }
699
700        // 15. If path is the empty string, then set path to the serialized cookie default path of url.
701        let path = if properties.path.is_empty() {
702            // 15.1 Let cloneURL be a clone of url.
703            let mut cloned_url = url.clone();
704            {
705                // 15.2 Set cloneURL’s path to the cookie default path of cloneURL’s path.
706                // <https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-layered-cookies#name-cookie-default-path>
707                let mut path_segments = cloned_url
708                    .as_mut_url()
709                    .path_segments_mut()
710                    .expect("document creation url cannot be a base");
711                // 2. If path's size is greater than 1, then remove path's last item
712                if url.path_segments().is_some_and(|ps| ps.count() > 1) {
713                    path_segments.pop();
714                } else {
715                    // 3. Otherwise, set path[0] to the empty string.
716                    path_segments.clear();
717                }
718            }
719            cloned_url.path().to_owned()
720        } else {
721            properties.path.to_string()
722        };
723        // 16. If path does not start with U+002F (/), then return failure.
724        if !path.starts_with('/') {
725            return None;
726        }
727        // 17. If path is not U+002F (/), and name, byte-lowercased, starts with `__host-`, then return failure.
728        if path != "/" && lowercased_name.starts_with("__host-") {
729            return None;
730        }
731        // 19. If the byte sequence length of encodedPath is greater than the maximum attribute value size, then return failure.
732        if path.len() > 1024 {
733            return None;
734        }
735        // 20. Append (`Path`, encodedPath) to attributes.
736        cookie.inner_mut().set_path(path);
737
738        Some(cookie.build())
739    }
740
741    /// <https://cookiestore.spec.whatwg.org/#set-cookie-algorithm>
742    fn contains_control_characters(val: &str) -> bool {
743        // If name or value contain U+003B (;), any C0 control character except U+0009 TAB, or U+007F DELETE, then return failure.
744        val.contains(
745            |v| matches!(v, '\u{0000}'..='\u{0008}' | '\u{000a}'..='\u{001f}' | '\u{007f}' | ';'),
746        )
747    }
748}