Skip to main content

rama_http_headers/common/permissions_policy/
mod.rs

1//! `Permissions-Policy` header — [W3C Permissions Policy](https://www.w3.org/TR/permissions-policy/).
2//!
3//! Comma-separated list of `feature=(allowlist)` entries. Built from
4//! typed [`PermissionsPolicyDirective`]s; per-feature deny-all
5//! shortcuts (`with_deny_camera`, `set_deny_microphone`, …) are
6//! generated via [`rama_utils::macros::generate_set_and_with`].
7
8mod directive;
9
10pub use self::directive::{
11    AllowlistSource, PermissionsPolicyDirective, PermissionsPolicyDirectiveName,
12};
13
14use std::fmt;
15
16use rama_http_types::{HeaderName, HeaderValue};
17use rama_utils::macros::generate_set_and_with;
18
19use crate::{Error, HeaderDecode, HeaderEncode, TypedHeader};
20
21/// `Permissions-Policy` response header.
22///
23/// Adding a directive that already exists in the policy *replaces* its
24/// allow-list in place (preserving declared order). The user agent
25/// would treat the second occurrence as the winner per RFC 8941
26/// structured-fields anyway, so we collapse to the caller-most-recent
27/// value.
28///
29/// # Examples
30///
31/// Deny the common ambient-capability features:
32///
33/// ```
34/// use rama_http_headers::PermissionsPolicy;
35///
36/// let pp = PermissionsPolicy::empty()
37///     .with_deny_camera()
38///     .with_deny_microphone()
39///     .with_deny_geolocation()
40///     .with_deny_payment()
41///     .with_deny_usb()
42///     .with_deny_interest_cohort();
43///
44/// let rendered = pp.to_string();
45/// assert!(rendered.contains("camera=()"));
46/// assert!(rendered.contains("interest-cohort=()"));
47/// ```
48///
49/// Drop down to the generic surface for an allow-list or for a
50/// proposed/draft feature that isn't yet modelled:
51///
52/// ```
53/// use rama_http_headers::{
54///     PermissionsPolicy, PermissionsPolicyDirective, PermissionsPolicyDirectiveName,
55///     AllowlistSource,
56/// };
57///
58/// let pp = PermissionsPolicy::empty()
59///     .with_directive(PermissionsPolicyDirective::allow(
60///         PermissionsPolicyDirectiveName::Camera,
61///         AllowlistSource::SelfOrigin,
62///     ))
63///     .with_directive(PermissionsPolicyDirective::deny(
64///         // Unknown / vendor / draft feature names land in the
65///         // auto-generated `Unknown` variant via `From<&str>`.
66///         PermissionsPolicyDirectiveName::from("x-vendor-experimental"),
67///     ));
68/// assert_eq!(pp.to_string(), "camera=(self), x-vendor-experimental=()");
69/// ```
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub struct PermissionsPolicy {
72    directives: Vec<PermissionsPolicyDirective>,
73}
74
75impl PermissionsPolicy {
76    /// Empty policy. Build from this when adding directives one at a
77    /// time.
78    #[must_use]
79    pub const fn empty() -> Self {
80        Self {
81            directives: Vec::new(),
82        }
83    }
84
85    /// Iterate the policy's directives in encoding order.
86    pub fn directives(&self) -> impl Iterator<Item = &PermissionsPolicyDirective> + '_ {
87        self.directives.iter()
88    }
89
90    generate_set_and_with! {
91        /// Generic escape hatch: append or replace a directive by
92        /// name. If a directive with the same name exists, its
93        /// allow-list is overwritten in place (order preserved);
94        /// otherwise it's appended.
95        ///
96        /// The macro generates the `&mut self` sibling
97        /// [`set_directive`](Self::set_directive).
98        pub fn directive(mut self, directive: PermissionsPolicyDirective) -> Self {
99            if let Some(slot) = self
100                .directives
101                .iter_mut()
102                .find(|d| d.name == directive.name)
103            {
104                slot.allow_list = directive.allow_list;
105            } else {
106                self.directives.push(directive);
107            }
108            self
109        }
110    }
111
112    // ---- per-directive deny-all shortcuts ----
113    //
114    // Each macro invocation generates both a `with_deny_*` (chaining,
115    // takes ownership) and a `set_deny_*` (`&mut self`) form. Body
116    // routes through `set_directive` so all paths share one canonical
117    // write — taking advantage of the `&mut Self` return on the
118    // generated setter.
119
120    generate_set_and_with! {
121        /// Set `camera=()` (deny-all).
122        pub fn deny_camera(mut self) -> Self {
123            self.set_directive(PermissionsPolicyDirective::deny(
124                PermissionsPolicyDirectiveName::Camera,
125            ));
126            self
127        }
128    }
129    generate_set_and_with! {
130        /// Set `microphone=()` (deny-all).
131        pub fn deny_microphone(mut self) -> Self {
132            self.set_directive(PermissionsPolicyDirective::deny(
133                PermissionsPolicyDirectiveName::Microphone,
134            ));
135            self
136        }
137    }
138    generate_set_and_with! {
139        /// Set `geolocation=()` (deny-all).
140        pub fn deny_geolocation(mut self) -> Self {
141            self.set_directive(PermissionsPolicyDirective::deny(
142                PermissionsPolicyDirectiveName::Geolocation,
143            ));
144            self
145        }
146    }
147    generate_set_and_with! {
148        /// Set `payment=()` (deny-all).
149        pub fn deny_payment(mut self) -> Self {
150            self.set_directive(PermissionsPolicyDirective::deny(
151                PermissionsPolicyDirectiveName::Payment,
152            ));
153            self
154        }
155    }
156    generate_set_and_with! {
157        /// Set `usb=()` (deny-all).
158        pub fn deny_usb(mut self) -> Self {
159            self.set_directive(PermissionsPolicyDirective::deny(
160                PermissionsPolicyDirectiveName::Usb,
161            ));
162            self
163        }
164    }
165    generate_set_and_with! {
166        /// Set `interest-cohort=()` (deny-all). Opts the site out of
167        /// the deprecated FLoC experiment. Pair with
168        /// [`deny_browsing_topics`](Self::with_deny_browsing_topics)
169        /// to also block Topics API, FLoC's shipped successor.
170        pub fn deny_interest_cohort(mut self) -> Self {
171            self.set_directive(PermissionsPolicyDirective::deny(
172                PermissionsPolicyDirectiveName::InterestCohort,
173            ));
174            self
175        }
176    }
177    generate_set_and_with! {
178        /// Set `browsing-topics=()` (deny-all). Opts the site out of
179        /// the Topics API (Privacy Sandbox).
180        pub fn deny_browsing_topics(mut self) -> Self {
181            self.set_directive(PermissionsPolicyDirective::deny(
182                PermissionsPolicyDirectiveName::BrowsingTopics,
183            ));
184            self
185        }
186    }
187    generate_set_and_with! {
188        /// Set `attribution-reporting=()` (deny-all). Opts the site
189        /// out of the Attribution Reporting API (Privacy Sandbox).
190        pub fn deny_attribution_reporting(mut self) -> Self {
191            self.set_directive(PermissionsPolicyDirective::deny(
192                PermissionsPolicyDirectiveName::AttributionReporting,
193            ));
194            self
195        }
196    }
197    generate_set_and_with! {
198        /// Set `accelerometer=()` (deny-all).
199        pub fn deny_accelerometer(mut self) -> Self {
200            self.set_directive(PermissionsPolicyDirective::deny(
201                PermissionsPolicyDirectiveName::Accelerometer,
202            ));
203            self
204        }
205    }
206    generate_set_and_with! {
207        /// Set `ambient-light-sensor=()` (deny-all).
208        pub fn deny_ambient_light_sensor(mut self) -> Self {
209            self.set_directive(PermissionsPolicyDirective::deny(
210                PermissionsPolicyDirectiveName::AmbientLightSensor,
211            ));
212            self
213        }
214    }
215    generate_set_and_with! {
216        /// Set `autoplay=()` (deny-all).
217        pub fn deny_autoplay(mut self) -> Self {
218            self.set_directive(PermissionsPolicyDirective::deny(
219                PermissionsPolicyDirectiveName::Autoplay,
220            ));
221            self
222        }
223    }
224    generate_set_and_with! {
225        /// Set `battery=()` (deny-all).
226        pub fn deny_battery(mut self) -> Self {
227            self.set_directive(PermissionsPolicyDirective::deny(
228                PermissionsPolicyDirectiveName::Battery,
229            ));
230            self
231        }
232    }
233    generate_set_and_with! {
234        /// Set `bluetooth=()` (deny-all).
235        pub fn deny_bluetooth(mut self) -> Self {
236            self.set_directive(PermissionsPolicyDirective::deny(
237                PermissionsPolicyDirectiveName::Bluetooth,
238            ));
239            self
240        }
241    }
242    generate_set_and_with! {
243        /// Set `display-capture=()` (deny-all).
244        pub fn deny_display_capture(mut self) -> Self {
245            self.set_directive(PermissionsPolicyDirective::deny(
246                PermissionsPolicyDirectiveName::DisplayCapture,
247            ));
248            self
249        }
250    }
251    generate_set_and_with! {
252        /// Set `encrypted-media=()` (deny-all).
253        pub fn deny_encrypted_media(mut self) -> Self {
254            self.set_directive(PermissionsPolicyDirective::deny(
255                PermissionsPolicyDirectiveName::EncryptedMedia,
256            ));
257            self
258        }
259    }
260    generate_set_and_with! {
261        /// Set `fullscreen=()` (deny-all).
262        pub fn deny_fullscreen(mut self) -> Self {
263            self.set_directive(PermissionsPolicyDirective::deny(
264                PermissionsPolicyDirectiveName::Fullscreen,
265            ));
266            self
267        }
268    }
269    generate_set_and_with! {
270        /// Set `gyroscope=()` (deny-all).
271        pub fn deny_gyroscope(mut self) -> Self {
272            self.set_directive(PermissionsPolicyDirective::deny(
273                PermissionsPolicyDirectiveName::Gyroscope,
274            ));
275            self
276        }
277    }
278    generate_set_and_with! {
279        /// Set `idle-detection=()` (deny-all).
280        pub fn deny_idle_detection(mut self) -> Self {
281            self.set_directive(PermissionsPolicyDirective::deny(
282                PermissionsPolicyDirectiveName::IdleDetection,
283            ));
284            self
285        }
286    }
287    generate_set_and_with! {
288        /// Set `magnetometer=()` (deny-all).
289        pub fn deny_magnetometer(mut self) -> Self {
290            self.set_directive(PermissionsPolicyDirective::deny(
291                PermissionsPolicyDirectiveName::Magnetometer,
292            ));
293            self
294        }
295    }
296    generate_set_and_with! {
297        /// Set `midi=()` (deny-all).
298        pub fn deny_midi(mut self) -> Self {
299            self.set_directive(PermissionsPolicyDirective::deny(
300                PermissionsPolicyDirectiveName::Midi,
301            ));
302            self
303        }
304    }
305    generate_set_and_with! {
306        /// Set `picture-in-picture=()` (deny-all).
307        pub fn deny_picture_in_picture(mut self) -> Self {
308            self.set_directive(PermissionsPolicyDirective::deny(
309                PermissionsPolicyDirectiveName::PictureInPicture,
310            ));
311            self
312        }
313    }
314    generate_set_and_with! {
315        /// Set `publickey-credentials-get=()` (deny-all).
316        pub fn deny_publickey_credentials_get(mut self) -> Self {
317            self.set_directive(PermissionsPolicyDirective::deny(
318                PermissionsPolicyDirectiveName::PublickeyCredentialsGet,
319            ));
320            self
321        }
322    }
323    generate_set_and_with! {
324        /// Set `screen-wake-lock=()` (deny-all).
325        pub fn deny_screen_wake_lock(mut self) -> Self {
326            self.set_directive(PermissionsPolicyDirective::deny(
327                PermissionsPolicyDirectiveName::ScreenWakeLock,
328            ));
329            self
330        }
331    }
332    generate_set_and_with! {
333        /// Set `sync-xhr=()` (deny-all).
334        pub fn deny_sync_xhr(mut self) -> Self {
335            self.set_directive(PermissionsPolicyDirective::deny(
336                PermissionsPolicyDirectiveName::SyncXhr,
337            ));
338            self
339        }
340    }
341    generate_set_and_with! {
342        /// Set `web-share=()` (deny-all).
343        pub fn deny_web_share(mut self) -> Self {
344            self.set_directive(PermissionsPolicyDirective::deny(
345                PermissionsPolicyDirectiveName::WebShare,
346            ));
347            self
348        }
349    }
350    generate_set_and_with! {
351        /// Set `xr-spatial-tracking=()` (deny-all).
352        pub fn deny_xr_spatial_tracking(mut self) -> Self {
353            self.set_directive(PermissionsPolicyDirective::deny(
354                PermissionsPolicyDirectiveName::XrSpatialTracking,
355            ));
356            self
357        }
358    }
359}
360
361impl fmt::Display for PermissionsPolicy {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        for (i, d) in self.directives.iter().enumerate() {
364            if i > 0 {
365                f.write_str(", ")?;
366            }
367            fmt::Display::fmt(d, f)?;
368        }
369        Ok(())
370    }
371}
372
373impl TypedHeader for PermissionsPolicy {
374    fn name() -> &'static HeaderName {
375        &::rama_http_types::header::PERMISSIONS_POLICY
376    }
377}
378
379impl HeaderDecode for PermissionsPolicy {
380    fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, Error> {
381        // The spec allows the header to be set multiple times — the
382        // user agent intersects all returned policies. For round-
383        // tripping we concatenate them preserving order, then route
384        // through `set_directive` so repeats collapse to the
385        // last-seen allow-list.
386        let mut out = Self::empty();
387        let mut any = false;
388        for value in values {
389            any = true;
390            let s = value.to_str().map_err(|_err| Error::invalid())?;
391            for raw in split_top_level_commas(s) {
392                let trimmed = raw.trim();
393                if trimmed.is_empty() {
394                    continue;
395                }
396                let Some(directive) = parse_directive(trimmed) else {
397                    // Drop malformed directives, keep the rest. The
398                    // alternative would be to fail the whole header
399                    // on a single bad token, which would be more
400                    // surprising than logging it and moving on.
401                    continue;
402                };
403                out.set_directive(directive);
404            }
405        }
406        if !any {
407            return Err(Error::invalid());
408        }
409        Ok(out)
410    }
411}
412
413impl HeaderEncode for PermissionsPolicy {
414    fn encode<E: Extend<HeaderValue>>(&self, values: &mut E) {
415        let rendered = self.to_string();
416        match HeaderValue::try_from(rendered) {
417            Ok(v) => values.extend(::std::iter::once(v)),
418            Err(_) => {
419                values.extend(::std::iter::once(HeaderValue::from_static("")));
420            }
421        }
422    }
423}
424
425/// Split the header value on commas that are not inside `()`. The
426/// allow-list is parenthesised, so a comma inside an allow-list isn't
427/// the directive separator. (Tokens themselves don't contain commas,
428/// and origin sf-strings don't either by spec.)
429fn split_top_level_commas(s: &str) -> impl Iterator<Item = &str> {
430    let bytes = s.as_bytes();
431    let mut start = 0usize;
432    let mut depth = 0i32;
433    let mut out: Vec<&str> = Vec::new();
434    for (i, b) in bytes.iter().enumerate() {
435        match b {
436            b'(' => depth += 1,
437            b')' => depth = depth.saturating_sub(1),
438            b',' if depth == 0 => {
439                out.push(&s[start..i]);
440                start = i + 1;
441            }
442            _ => {}
443        }
444    }
445    if start <= s.len() {
446        out.push(&s[start..]);
447    }
448    out.into_iter()
449}
450
451fn parse_directive(s: &str) -> Option<PermissionsPolicyDirective> {
452    let eq = s.find('=')?;
453    let name_raw = s[..eq].trim();
454    let value_raw = s[eq + 1..].trim();
455    if name_raw.is_empty() {
456        return None;
457    }
458    let inner = value_raw
459        .strip_prefix('(')
460        .and_then(|t| t.strip_suffix(')'))?;
461    // `From<&str>` on the @String enum is case-insensitive and falls
462    // through to `Unknown(String)` for unrecognised tokens — exactly
463    // the spec semantic (preserve declared name, even if not in the
464    // typed registry).
465    let name = PermissionsPolicyDirectiveName::from(name_raw);
466    let allow_list = inner
467        .split_whitespace()
468        .filter_map(AllowlistSource::from_token)
469        .collect();
470    Some(PermissionsPolicyDirective { name, allow_list })
471}
472
473#[cfg(test)]
474mod tests {
475    use super::super::{test_decode, test_encode};
476    use super::*;
477
478    #[test]
479    fn empty_renders_to_empty_string() {
480        let pp = PermissionsPolicy::empty();
481        assert_eq!(pp.to_string(), "");
482    }
483
484    #[test]
485    fn keyword_shortcuts_render_deny_all_chain() {
486        let pp = PermissionsPolicy::empty()
487            .with_deny_camera()
488            .with_deny_microphone()
489            .with_deny_geolocation();
490        assert_eq!(pp.to_string(), "camera=(), microphone=(), geolocation=()");
491    }
492
493    #[test]
494    fn keyword_shortcuts_share_path_with_generic_with() {
495        // The shortcut and the generic hatch should produce identical
496        // typed state, which falls out of routing both through
497        // `set_directive`.
498        let via_shortcut = PermissionsPolicy::empty().with_deny_camera();
499        let via_generic = PermissionsPolicy::empty().with_directive(
500            PermissionsPolicyDirective::deny(PermissionsPolicyDirectiveName::Camera),
501        );
502        assert_eq!(via_shortcut, via_generic);
503    }
504
505    #[test]
506    fn set_mutates_in_place() {
507        let mut pp = PermissionsPolicy::empty();
508        pp.set_deny_camera();
509        pp.set_directive(PermissionsPolicyDirective::allow(
510            PermissionsPolicyDirectiveName::Microphone,
511            AllowlistSource::SelfOrigin,
512        ));
513        assert_eq!(pp.to_string(), "camera=(), microphone=(self)");
514    }
515
516    #[test]
517    fn allow_list_self_and_origin_render() {
518        let pp = PermissionsPolicy::empty().with_directive(PermissionsPolicyDirective::allow_from(
519            PermissionsPolicyDirectiveName::Camera,
520            [
521                AllowlistSource::SelfOrigin,
522                AllowlistSource::origin("https://example.com"),
523            ],
524        ));
525        assert_eq!(pp.to_string(), r#"camera=(self "https://example.com")"#);
526    }
527
528    #[test]
529    fn wildcard_and_src_render() {
530        let pp_wild = PermissionsPolicy::empty().with_directive(PermissionsPolicyDirective::allow(
531            PermissionsPolicyDirectiveName::Camera,
532            AllowlistSource::Wildcard,
533        ));
534        assert_eq!(pp_wild.to_string(), "camera=(*)");
535
536        let pp_src = PermissionsPolicy::empty().with_directive(PermissionsPolicyDirective::allow(
537            PermissionsPolicyDirectiveName::Camera,
538            AllowlistSource::Src,
539        ));
540        assert_eq!(pp_src.to_string(), "camera=(src)");
541    }
542
543    #[test]
544    fn unknown_feature_via_other_round_trips() {
545        // Use a vendor-prefixed name that's deliberately not in the
546        // typed-variant set so the round-trip exercises the auto-
547        // generated `Unknown` path. (The registry grows over time,
548        // so any name we picked would risk becoming a real variant
549        // in a future revision.)
550        let pp = PermissionsPolicy::empty().with_directive(PermissionsPolicyDirective::deny(
551            PermissionsPolicyDirectiveName::from("x-vendor-experimental"),
552        ));
553        assert_eq!(pp.to_string(), "x-vendor-experimental=()");
554        let parsed = test_decode::<PermissionsPolicy>(&[pp.to_string().as_str()]).expect("decode");
555        assert_eq!(parsed, pp);
556    }
557
558    #[test]
559    fn decode_parses_canonical_deny_all_chain() {
560        let parsed = test_decode::<PermissionsPolicy>(&[
561            "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
562        ])
563        .expect("decode");
564        let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
565        assert_eq!(
566            names,
567            vec![
568                "camera",
569                "microphone",
570                "geolocation",
571                "payment",
572                "usb",
573                "interest-cohort",
574            ]
575        );
576        for d in parsed.directives() {
577            assert!(
578                d.allow_list.is_empty(),
579                "{} should be deny-all",
580                d.name.as_str()
581            );
582        }
583    }
584
585    #[test]
586    fn decode_preserves_declared_order() {
587        let parsed = test_decode::<PermissionsPolicy>(&["usb=(), camera=()"]).expect("decode");
588        let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
589        assert_eq!(names, vec!["usb", "camera"]);
590    }
591
592    #[test]
593    fn decode_collapses_repeated_feature_last_wins() {
594        let parsed =
595            test_decode::<PermissionsPolicy>(&["camera=(), camera=(self)"]).expect("decode");
596        let directives: Vec<_> = parsed.directives().collect();
597        assert_eq!(directives.len(), 1);
598        assert_eq!(directives[0].name, PermissionsPolicyDirectiveName::Camera);
599        assert_eq!(
600            directives[0].allow_list.as_slice(),
601            &[AllowlistSource::SelfOrigin]
602        );
603    }
604
605    #[test]
606    fn decode_handles_multiple_header_values() {
607        let parsed = test_decode::<PermissionsPolicy>(&["camera=()", "microphone=()"])
608            .expect("decode multi-value");
609        let names: Vec<&str> = parsed.directives().map(|d| d.name.as_str()).collect();
610        assert_eq!(names, vec!["camera", "microphone"]);
611    }
612
613    #[test]
614    fn decode_tolerates_whitespace() {
615        let parsed =
616            test_decode::<PermissionsPolicy>(&["  camera = ( self )  ,  microphone = ( )  "])
617                .expect("decode whitespace-heavy");
618        let directives: Vec<_> = parsed.directives().collect();
619        assert_eq!(directives.len(), 2);
620        assert_eq!(
621            directives[0].allow_list.as_slice(),
622            &[AllowlistSource::SelfOrigin]
623        );
624        assert!(directives[1].allow_list.is_empty());
625    }
626
627    #[test]
628    fn decode_case_insensitive_on_known_features() {
629        let parsed = test_decode::<PermissionsPolicy>(&["Camera=()"]).expect("decode");
630        let directives: Vec<_> = parsed.directives().collect();
631        assert_eq!(directives.len(), 1);
632        assert_eq!(directives[0].name, PermissionsPolicyDirectiveName::Camera);
633    }
634
635    #[test]
636    fn decode_mixed_sources() {
637        let parsed = test_decode::<PermissionsPolicy>(&[r#"camera=(self "https://a.example" *)"#])
638            .expect("decode");
639        let directives: Vec<_> = parsed.directives().collect();
640        assert_eq!(directives.len(), 1);
641        assert_eq!(
642            directives[0].allow_list.as_slice(),
643            &[
644                AllowlistSource::SelfOrigin,
645                AllowlistSource::origin("https://a.example"),
646                AllowlistSource::Wildcard,
647            ]
648        );
649    }
650
651    #[test]
652    fn decode_empty_returns_error() {
653        assert_eq!(test_decode::<PermissionsPolicy>(&[] as &[&str]), None);
654    }
655
656    #[test]
657    fn newer_feature_names_round_trip_as_typed_variants() {
658        // Regression for the post-ticket spec audit: these used to
659        // fall through to `Other(...)`. They should now parse to
660        // their canonical typed variants.
661        for (token, expected) in [
662            (
663                "browsing-topics",
664                PermissionsPolicyDirectiveName::BrowsingTopics,
665            ),
666            (
667                "attribution-reporting",
668                PermissionsPolicyDirectiveName::AttributionReporting,
669            ),
670            (
671                "clipboard-read",
672                PermissionsPolicyDirectiveName::ClipboardRead,
673            ),
674            (
675                "clipboard-write",
676                PermissionsPolicyDirectiveName::ClipboardWrite,
677            ),
678            (
679                "compute-pressure",
680                PermissionsPolicyDirectiveName::ComputePressure,
681            ),
682            ("gamepad", PermissionsPolicyDirectiveName::Gamepad),
683            ("hid", PermissionsPolicyDirectiveName::Hid),
684            ("serial", PermissionsPolicyDirectiveName::Serial),
685            (
686                "storage-access",
687                PermissionsPolicyDirectiveName::StorageAccess,
688            ),
689            (
690                "publickey-credentials-create",
691                PermissionsPolicyDirectiveName::PublickeyCredentialsCreate,
692            ),
693            (
694                "window-management",
695                PermissionsPolicyDirectiveName::WindowManagement,
696            ),
697            ("local-fonts", PermissionsPolicyDirectiveName::LocalFonts),
698            ("unload", PermissionsPolicyDirectiveName::Unload),
699        ] {
700            let raw = format!("{token}=()");
701            let parsed = test_decode::<PermissionsPolicy>(&[raw.as_str()])
702                .unwrap_or_else(|| panic!("decode {token}"));
703            let directive = parsed.directives().next().expect("one directive");
704            assert_eq!(directive.name, expected, "token `{token}` parsed wrong");
705            assert_eq!(parsed.to_string(), raw, "round-trip changed `{token}`");
706        }
707    }
708
709    #[test]
710    fn topics_and_attribution_shortcuts_render_canonical_tokens() {
711        let pp = PermissionsPolicy::empty()
712            .with_deny_interest_cohort()
713            .with_deny_browsing_topics()
714            .with_deny_attribution_reporting();
715        assert_eq!(
716            pp.to_string(),
717            "interest-cohort=(), browsing-topics=(), attribution-reporting=()",
718        );
719    }
720
721    #[test]
722    fn encode_round_trips_through_header_map() {
723        let pp = PermissionsPolicy::empty()
724            .with_deny_camera()
725            .with_deny_microphone()
726            .with_directive(PermissionsPolicyDirective::allow_from(
727                PermissionsPolicyDirectiveName::Geolocation,
728                [
729                    AllowlistSource::SelfOrigin,
730                    AllowlistSource::origin("https://example.com"),
731                ],
732            ));
733        let map = test_encode(pp.clone());
734        let raw = map
735            .get(PermissionsPolicy::name())
736            .expect("set")
737            .to_str()
738            .unwrap()
739            .to_owned();
740        assert_eq!(raw, pp.to_string());
741        let parsed = test_decode::<PermissionsPolicy>(&[raw.as_str()]).expect("decode");
742        assert_eq!(parsed, pp);
743    }
744}