Skip to main content

gatekeep_fluent/
lib.rs

1//! Fluent-backed reason catalog for gatekeep denial reasons.
2//!
3//! ```
4//! use gatekeep::{DenialReason, DenyShape, Locale, ReasonCatalog, ReasonCode};
5//! use gatekeep_fluent::FluentCatalog;
6//!
7//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
8//! let catalog = FluentCatalog::new()
9//!     .with_resource("en-US", "case-read-denied = You cannot read this case.")?;
10//! let reason = DenialReason {
11//!     code: ReasonCode::new("case-read-denied")?,
12//!     params: Default::default(),
13//!     shape: DenyShape::Forbidden,
14//! };
15//!
16//! assert_eq!(
17//!     catalog.render(&reason, &Locale::new("en-US")?),
18//!     "You cannot read this case."
19//! );
20//! # Ok(())
21//! # }
22//! ```
23
24use std::collections::BTreeMap;
25
26use fluent_bundle::{
27    FluentArgs, FluentError, FluentResource, FluentValue, concurrent::FluentBundle,
28};
29use gatekeep::{DenialReason, DenyShape, Locale, ReasonCatalog, ReasonValue};
30use thiserror::Error;
31use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
32
33const DEFAULT_HIDDEN_MESSAGE: &str = "not found";
34const DEFAULT_HIDDEN_MESSAGE_ID: &str = "not-found";
35
36/// Fluent catalog keyed by gatekeep reason codes.
37pub struct FluentCatalog {
38    bundles: BTreeMap<String, FluentBundle<FluentResource>>,
39    fallback_locale: Option<String>,
40    hidden_message_id: String,
41    hidden_fallback: String,
42}
43
44impl Default for FluentCatalog {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl FluentCatalog {
51    /// Creates an empty catalog.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            bundles: BTreeMap::new(),
56            fallback_locale: None,
57            hidden_message_id: DEFAULT_HIDDEN_MESSAGE_ID.to_owned(),
58            hidden_fallback: DEFAULT_HIDDEN_MESSAGE.to_owned(),
59        }
60    }
61
62    /// Sets the locale to try after the requested locale cannot render.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`FluentCatalogError::InvalidLocale`] when `locale` is not a
67    /// well-formed Unicode language identifier.
68    pub fn set_fallback_locale(
69        &mut self,
70        locale: impl AsRef<str>,
71    ) -> Result<(), FluentCatalogError> {
72        let locale = parse_locale(locale.as_ref())?;
73        self.fallback_locale = Some(locale.to_string());
74        Ok(())
75    }
76
77    /// Returns this catalog with a fallback locale configured.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`FluentCatalogError::InvalidLocale`] when `locale` is not a
82    /// well-formed Unicode language identifier.
83    pub fn with_fallback_locale(
84        mut self,
85        locale: impl AsRef<str>,
86    ) -> Result<Self, FluentCatalogError> {
87        self.set_fallback_locale(locale)?;
88        Ok(self)
89    }
90
91    /// Sets the generic message used for hidden denials.
92    ///
93    /// Hidden denials must not render their specific reason code because that
94    /// can disclose the protected resource's existence. The catalog looks up
95    /// `message_id` in the requested locale and then the fallback locale; if no
96    /// message exists, it returns `fallback`.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`FluentCatalogError::EmptyMessageId`] when `message_id` is blank
101    /// or [`FluentCatalogError::EmptyFallbackMessage`] when `fallback` is blank.
102    pub fn set_hidden_message(
103        &mut self,
104        message_id: impl Into<String>,
105        fallback: impl Into<String>,
106    ) -> Result<(), FluentCatalogError> {
107        self.hidden_message_id = validate_message_id(message_id.into())?;
108        self.hidden_fallback = validate_hidden_fallback(fallback.into())?;
109        Ok(())
110    }
111
112    /// Returns this catalog with a generic hidden-denial message configured.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`FluentCatalogError::EmptyMessageId`] when `message_id` is blank
117    /// or [`FluentCatalogError::EmptyFallbackMessage`] when `fallback` is blank.
118    pub fn with_hidden_message(
119        mut self,
120        message_id: impl Into<String>,
121        fallback: impl Into<String>,
122    ) -> Result<Self, FluentCatalogError> {
123        self.set_hidden_message(message_id, fallback)?;
124        Ok(self)
125    }
126
127    /// Adds an FTL resource to the bundle for `locale`.
128    ///
129    /// Multiple resources may be added to one locale. Fluent duplicate-message
130    /// errors are reported instead of overriding an earlier message.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`FluentCatalogError`] when the locale is invalid, the FTL source
135    /// has parse errors, or Fluent rejects the resource for that locale.
136    pub fn add_resource(
137        &mut self,
138        locale: impl AsRef<str>,
139        source: impl Into<String>,
140    ) -> Result<(), FluentCatalogError> {
141        let locale = parse_locale(locale.as_ref())?;
142        let locale_key = locale.to_string();
143        let resource = parse_resource(&locale_key, source.into())?;
144        let bundle = self
145            .bundles
146            .entry(locale_key.clone())
147            .or_insert_with(|| FluentBundle::new_concurrent(vec![locale]));
148        bundle
149            .add_resource(resource)
150            .map_err(|errors| FluentCatalogError::Resource {
151                locale: locale_key,
152                errors: display_errors(errors),
153            })
154    }
155
156    /// Returns this catalog with an FTL resource added.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`FluentCatalogError`] when the resource cannot be added.
161    pub fn with_resource(
162        mut self,
163        locale: impl AsRef<str>,
164        source: impl Into<String>,
165    ) -> Result<Self, FluentCatalogError> {
166        self.add_resource(locale, source)?;
167        Ok(self)
168    }
169
170    /// Renders `reason`, falling back to the stable reason code for forbidden
171    /// denials or to the generic hidden message for hidden denials.
172    #[must_use]
173    pub fn render_reason(&self, reason: &DenialReason, locale: &Locale) -> String {
174        if reason.shape == DenyShape::Hidden {
175            return self.render_hidden(locale);
176        }
177        self.try_render_reason(reason, locale)
178            .unwrap_or_else(|| reason.code.as_str().to_owned())
179    }
180
181    /// Renders `reason` only when a matching Fluent message exists and resolves
182    /// without runtime formatting errors.
183    ///
184    /// Hidden denials use the configured generic hidden message id instead of
185    /// `reason.code`.
186    #[must_use]
187    pub fn try_render_reason(&self, reason: &DenialReason, locale: &Locale) -> Option<String> {
188        if reason.shape == DenyShape::Hidden {
189            return self.try_render_hidden(locale);
190        }
191        for locale_key in self.locale_candidates(locale) {
192            if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
193                return Some(rendered);
194            }
195        }
196        None
197    }
198
199    fn render_hidden(&self, locale: &Locale) -> String {
200        self.try_render_hidden(locale)
201            .unwrap_or_else(|| self.hidden_fallback.clone())
202    }
203
204    fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
205        for locale_key in self.locale_candidates(locale) {
206            if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
207            {
208                return Some(rendered);
209            }
210        }
211        None
212    }
213
214    fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
215        let mut candidates = Vec::new();
216        push_locale_candidate(&mut candidates, locale.as_str());
217        if let Some((language, _rest)) = locale.as_str().split_once('-') {
218            push_locale_candidate(&mut candidates, language);
219        }
220        if let Some(fallback_locale) = &self.fallback_locale {
221            push_locale_candidate(&mut candidates, fallback_locale);
222            if let Some((language, _rest)) = fallback_locale.split_once('-') {
223                push_locale_candidate(&mut candidates, language);
224            }
225        }
226        candidates
227    }
228
229    fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
230        let args = fluent_args(reason);
231        self.render_message(reason.code.as_str(), Some(&args), locale_key)
232    }
233
234    fn render_message(
235        &self,
236        message_id: &str,
237        args: Option<&FluentArgs<'_>>,
238        locale_key: &str,
239    ) -> Option<String> {
240        let bundle = self.bundles.get(locale_key)?;
241        let message = bundle.get_message(message_id)?;
242        let pattern = message.value()?;
243        let mut errors = Vec::new();
244        let rendered = bundle.format_pattern(pattern, args, &mut errors);
245        errors.is_empty().then(|| rendered.into_owned())
246    }
247}
248
249impl ReasonCatalog for FluentCatalog {
250    fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
251        self.render_reason(reason, locale)
252    }
253}
254
255/// Errors returned while building a [`FluentCatalog`].
256#[derive(Debug, Error, PartialEq)]
257pub enum FluentCatalogError {
258    /// The locale could not be parsed by `unic-langid`.
259    #[error("invalid fluent locale {locale}: {source}")]
260    InvalidLocale {
261        /// Rejected locale string.
262        locale: String,
263        /// Parser error returned by `unic-langid`.
264        source: LanguageIdentifierError,
265    },
266    /// FTL source could not be parsed.
267    #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
268    Parse {
269        /// Locale the resource was intended for.
270        locale: String,
271        /// Parse errors reported by Fluent.
272        errors: Vec<String>,
273    },
274    /// Fluent rejected the parsed resource.
275    #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
276    Resource {
277        /// Locale the resource was intended for.
278        locale: String,
279        /// Resource errors reported by Fluent.
280        errors: Vec<String>,
281    },
282    /// Hidden-denial generic message id was blank.
283    #[error("{field} must not be empty")]
284    EmptyMessageId {
285        /// Name of the message-id field.
286        field: &'static str,
287    },
288    /// Hidden-denial fallback message was blank.
289    #[error("{field} must not be empty")]
290    EmptyFallbackMessage {
291        /// Name of the fallback-message field.
292        field: &'static str,
293    },
294}
295
296fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
297    locale
298        .parse::<LanguageIdentifier>()
299        .map_err(|source| FluentCatalogError::InvalidLocale {
300            locale: locale.to_owned(),
301            source,
302        })
303}
304
305fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
306    FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
307        locale: locale.to_owned(),
308        errors: errors.into_iter().map(|error| error.to_string()).collect(),
309    })
310}
311
312fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
313    errors.into_iter().map(|error| error.to_string()).collect()
314}
315
316fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
317    if value.trim().is_empty() {
318        Err(FluentCatalogError::EmptyMessageId {
319            field: "hidden message id",
320        })
321    } else {
322        Ok(value)
323    }
324}
325
326fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
327    if value.trim().is_empty() {
328        Err(FluentCatalogError::EmptyFallbackMessage {
329            field: "hidden fallback message",
330        })
331    } else {
332        Ok(value)
333    }
334}
335
336fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
337    if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
338        let candidate = locale.to_string();
339        if !candidates.contains(&candidate) {
340            candidates.push(candidate);
341        }
342    }
343}
344
345fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
346    let mut args = FluentArgs::with_capacity(reason.params.len());
347    for (key, value) in &reason.params {
348        args.set(key.as_str().to_owned(), fluent_value(value));
349    }
350    args
351}
352
353fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
354    match value {
355        ReasonValue::Str(value) => FluentValue::from(value.clone()),
356        ReasonValue::Int(value) => FluentValue::from(*value),
357        ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
358        ReasonValue::Outcome(value) => json_value(value),
359    }
360}
361
362fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
363    match value {
364        serde_json::Value::Null => FluentValue::from("null"),
365        serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
366        serde_json::Value::Number(value) => number_value(value),
367        serde_json::Value::String(value) => FluentValue::from(value.clone()),
368        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
369            FluentValue::from(value.to_string())
370        }
371    }
372}
373
374fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
375    value
376        .as_i64()
377        .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
378}
379
380fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
381    value.as_u64().map_or_else(
382        || float_value(value),
383        |value| {
384            i64::try_from(value)
385                .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
386        },
387    )
388}
389
390fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
391    value
392        .as_f64()
393        .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
394}