rama_http_headers/common/permissions_policy/directive.rs
1use std::fmt;
2
3use rama_utils::collections::smallvec::{SmallVec, smallvec};
4use rama_utils::macros::enums::enum_builder;
5
6/// A single entry in a [`PermissionsPolicy`](super::PermissionsPolicy):
7/// pairs a feature name with the origins allowed to use it.
8///
9/// Construct via [`Self::deny`], [`Self::allow`], or [`Self::allow_from`]
10/// — those are the three shapes the spec actually defines.
11///
12/// Note: the [`PermissionsPolicy`](super::PermissionsPolicy) struct
13/// itself has per-feature `with_deny_*` / `set_deny_*` shortcuts for
14/// the deny-all case (the overwhelmingly common one); reach for these
15/// constructors when you need an actual allow-list.
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub struct PermissionsPolicyDirective {
18 pub name: PermissionsPolicyDirectiveName,
19 /// Empty allow-list serialises to `name=()` — the deny-all form.
20 /// The W3C spec has no `None` allow-list state, so the deny-all
21 /// directive is just an empty list.
22 pub allow_list: SmallVec<[AllowlistSource; 4]>,
23}
24
25impl PermissionsPolicyDirective {
26 /// Deny-all directive: `name=()`.
27 #[must_use]
28 pub fn deny(name: impl Into<PermissionsPolicyDirectiveName>) -> Self {
29 Self {
30 name: name.into(),
31 allow_list: SmallVec::new(),
32 }
33 }
34
35 /// Single-source allow-list: `name=(source)`.
36 #[must_use]
37 pub fn allow(name: impl Into<PermissionsPolicyDirectiveName>, source: AllowlistSource) -> Self {
38 Self {
39 name: name.into(),
40 allow_list: smallvec![source],
41 }
42 }
43
44 /// Multi-source allow-list: `name=(src1 src2 …)`.
45 #[must_use]
46 pub fn allow_from(
47 name: impl Into<PermissionsPolicyDirectiveName>,
48 sources: impl IntoIterator<Item = AllowlistSource>,
49 ) -> Self {
50 Self {
51 name: name.into(),
52 allow_list: sources.into_iter().collect(),
53 }
54 }
55}
56
57impl fmt::Display for PermissionsPolicyDirective {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 write!(f, "{}=(", self.name.as_str())?;
60 for (i, src) in self.allow_list.iter().enumerate() {
61 if i > 0 {
62 f.write_str(" ")?;
63 }
64 fmt::Display::fmt(src, f)?;
65 }
66 f.write_str(")")
67 }
68}
69
70enum_builder! {
71 /// W3C-registered feature names plus an escape hatch for the long
72 /// tail (proposed / draft / vendor-prefixed features).
73 ///
74 /// Comparisons are case-insensitive on the wire — the
75 /// [`From<&str>`](Self) impl folds unrecognised tokens into the
76 /// [`Unknown`](Self::Unknown) variant. The registry grows over
77 /// time so callers should expect to land there for less-common
78 /// features.
79 @String
80 pub enum PermissionsPolicyDirectiveName {
81 // ---- W3C registry / MDN, in spec order roughly ----
82 Accelerometer => "accelerometer",
83 AmbientLightSensor => "ambient-light-sensor",
84 AttributionReporting => "attribution-reporting",
85 Autoplay => "autoplay",
86 Battery => "battery",
87 Bluetooth => "bluetooth",
88 BrowsingTopics => "browsing-topics",
89 Camera => "camera",
90 ClipboardRead => "clipboard-read",
91 ClipboardWrite => "clipboard-write",
92 ComputePressure => "compute-pressure",
93 CrossOriginIsolated => "cross-origin-isolated",
94 DisplayCapture => "display-capture",
95 EncryptedMedia => "encrypted-media",
96 Fullscreen => "fullscreen",
97 Gamepad => "gamepad",
98 Geolocation => "geolocation",
99 Gyroscope => "gyroscope",
100 Hid => "hid",
101 IdentityCredentialsGet => "identity-credentials-get",
102 IdleDetection => "idle-detection",
103 /// Deprecated FLoC opt-out. Kept for parsing legacy policies;
104 /// the modern equivalent is `BrowsingTopics`.
105 InterestCohort => "interest-cohort",
106 LocalFonts => "local-fonts",
107 Magnetometer => "magnetometer",
108 Microphone => "microphone",
109 Midi => "midi",
110 OtpCredentials => "otp-credentials",
111 Payment => "payment",
112 PictureInPicture => "picture-in-picture",
113 PublickeyCredentialsCreate => "publickey-credentials-create",
114 PublickeyCredentialsGet => "publickey-credentials-get",
115 ScreenWakeLock => "screen-wake-lock",
116 Serial => "serial",
117 SpeakerSelection => "speaker-selection",
118 StorageAccess => "storage-access",
119 SyncXhr => "sync-xhr",
120 Unload => "unload",
121 Usb => "usb",
122 WebShare => "web-share",
123 WindowManagement => "window-management",
124 XrSpatialTracking => "xr-spatial-tracking",
125 }
126}
127
128enum_builder! {
129 /// Allowlist token shapes per the W3C Permissions Policy spec.
130 ///
131 /// The deny-all directive is represented by an empty
132 /// [`PermissionsPolicyDirective::allow_list`] (no `None` variant
133 /// here) so that the typed state and the wire form
134 /// (`feature=()`) line up one-to-one.
135 ///
136 /// Specific origins (e.g. `"https://example.com"`) land in the
137 /// auto-generated [`Unknown`](Self::Unknown) variant — use
138 /// [`Self::origin`] to construct one with the required RFC 8941
139 /// sf-string double-quoting baked in.
140 @String
141 pub enum AllowlistSource {
142 /// `self` — same-origin only (no surrounding quotes on the
143 /// wire, unlike CSP).
144 SelfOrigin => "self",
145 /// `*` — any origin.
146 Wildcard => "*",
147 /// `src` — legacy `<iframe allow=…>` token, lets the iframe
148 /// inherit from its `src` attribute. Rare outside iframe
149 /// context.
150 Src => "src",
151 }
152}
153
154impl AllowlistSource {
155 /// Construct an origin allow-list source from a bare origin string
156 /// — the surrounding RFC 8941 sf-string double-quotes are added
157 /// for you. Pass `https://example.com`, not `"https://example.com"`.
158 #[must_use]
159 pub fn origin(origin: impl AsRef<str>) -> Self {
160 Self::Unknown(format!("\"{}\"", origin.as_ref()).into())
161 }
162
163 /// Parse a single allowlist token. Returns `None` on a
164 /// structurally invalid token — caller decides whether to drop
165 /// the directive or just the token.
166 pub(crate) fn from_token(s: &str) -> Option<Self> {
167 if let Some(inner) = s.strip_prefix('"').and_then(|t| t.strip_suffix('"')) {
168 // Quoted sf-string origin. Keep the quotes on the stored
169 // value so Display (macro-generated for `Unknown`) emits
170 // the canonical wire form unchanged.
171 if inner.is_empty() {
172 None
173 } else {
174 Some(Self::Unknown(format!("\"{inner}\"").into()))
175 }
176 } else {
177 // Bare keyword: macro-generated `From<&str>` matches
178 // `self`/`*`/`src` case-insensitively and falls through
179 // to `Unknown(String)` otherwise. We reject the latter —
180 // only quoted strings are valid non-keyword tokens per
181 // the spec.
182 match Self::from(s) {
183 Self::Unknown(_) => None,
184 parsed => Some(parsed),
185 }
186 }
187 }
188}