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