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    /// Lists policy reason identifiers missing from this exact locale.
63    ///
64    /// This is a coverage check, not a rendering attempt. Fallback locales do
65    /// not hide untranslated messages, and runtime Fluent arguments still need
66    /// their own scenario tests. Hidden denials continue to use generic text.
67    #[must_use]
68    pub fn missing_reasons<'a, O>(
69        &self,
70        policy: &'a gatekeep::PreparedPolicy<O>,
71        locale: &Locale,
72    ) -> Vec<&'a str> {
73        let bundle = self.bundles.get(locale.as_str());
74        policy
75            .inspect()
76            .reasons
77            .into_iter()
78            .filter(|reason| {
79                bundle
80                    .and_then(|bundle| bundle.get_message(reason))
81                    .and_then(|message| message.value())
82                    .is_none()
83            })
84            .collect()
85    }
86
87    /// Sets the locale to try after the requested locale cannot render.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`FluentCatalogError::InvalidLocale`] when `locale` is not a
92    /// well-formed Unicode language identifier.
93    pub fn set_fallback_locale(
94        &mut self,
95        locale: impl AsRef<str>,
96    ) -> Result<(), FluentCatalogError> {
97        let locale = parse_locale(locale.as_ref())?;
98        self.fallback_locale = Some(locale.to_string());
99        Ok(())
100    }
101
102    /// Returns this catalog with a fallback locale configured.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`FluentCatalogError::InvalidLocale`] when `locale` is not a
107    /// well-formed Unicode language identifier.
108    pub fn with_fallback_locale(
109        mut self,
110        locale: impl AsRef<str>,
111    ) -> Result<Self, FluentCatalogError> {
112        self.set_fallback_locale(locale)?;
113        Ok(self)
114    }
115
116    /// Sets the generic message used for hidden denials.
117    ///
118    /// Hidden denials must not render their specific reason code because that
119    /// can disclose the protected resource's existence. The catalog looks up
120    /// `message_id` in the requested locale and then the fallback locale; if no
121    /// message exists, it returns `fallback`.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`FluentCatalogError::EmptyMessageId`] when `message_id` is blank
126    /// or [`FluentCatalogError::EmptyFallbackMessage`] when `fallback` is blank.
127    pub fn set_hidden_message(
128        &mut self,
129        message_id: impl Into<String>,
130        fallback: impl Into<String>,
131    ) -> Result<(), FluentCatalogError> {
132        self.hidden_message_id = validate_message_id(message_id.into())?;
133        self.hidden_fallback = validate_hidden_fallback(fallback.into())?;
134        Ok(())
135    }
136
137    /// Returns this catalog with a generic hidden-denial message configured.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`FluentCatalogError::EmptyMessageId`] when `message_id` is blank
142    /// or [`FluentCatalogError::EmptyFallbackMessage`] when `fallback` is blank.
143    pub fn with_hidden_message(
144        mut self,
145        message_id: impl Into<String>,
146        fallback: impl Into<String>,
147    ) -> Result<Self, FluentCatalogError> {
148        self.set_hidden_message(message_id, fallback)?;
149        Ok(self)
150    }
151
152    /// Adds an FTL resource to the bundle for `locale`.
153    ///
154    /// Multiple resources may be added to one locale. Fluent duplicate-message
155    /// errors are reported instead of overriding an earlier message.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`FluentCatalogError`] when the locale is invalid, the FTL source
160    /// has parse errors, or Fluent rejects the resource for that locale.
161    pub fn add_resource(
162        &mut self,
163        locale: impl AsRef<str>,
164        source: impl Into<String>,
165    ) -> Result<(), FluentCatalogError> {
166        let locale = parse_locale(locale.as_ref())?;
167        let locale_key = locale.to_string();
168        let resource = parse_resource(&locale_key, source.into())?;
169        let bundle = self
170            .bundles
171            .entry(locale_key.clone())
172            .or_insert_with(|| FluentBundle::new_concurrent(vec![locale]));
173        bundle
174            .add_resource(resource)
175            .map_err(|errors| FluentCatalogError::Resource {
176                locale: locale_key,
177                errors: display_errors(errors),
178            })
179    }
180
181    /// Returns this catalog with an FTL resource added.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`FluentCatalogError`] when the resource cannot be added.
186    pub fn with_resource(
187        mut self,
188        locale: impl AsRef<str>,
189        source: impl Into<String>,
190    ) -> Result<Self, FluentCatalogError> {
191        self.add_resource(locale, source)?;
192        Ok(self)
193    }
194
195    /// Renders `reason`, falling back to the stable reason code for forbidden
196    /// denials or to the generic hidden message for hidden denials.
197    #[must_use]
198    pub fn render_reason(&self, reason: &DenialReason, locale: &Locale) -> String {
199        if reason.shape == DenyShape::Hidden {
200            return self.render_hidden(locale);
201        }
202
203        self.try_render_reason(reason, locale)
204            .unwrap_or_else(|| reason.code.as_str().to_owned())
205    }
206
207    /// Renders `reason` only when a matching Fluent message exists and resolves
208    /// without runtime formatting errors.
209    ///
210    /// Hidden denials use the configured generic hidden message id instead of
211    /// `reason.code`.
212    #[must_use]
213    pub fn try_render_reason(&self, reason: &DenialReason, locale: &Locale) -> Option<String> {
214        if reason.shape == DenyShape::Hidden {
215            return self.try_render_hidden(locale);
216        }
217
218        for locale_key in self.locale_candidates(locale) {
219            if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
220                return Some(rendered);
221            }
222        }
223        None
224    }
225
226    fn render_hidden(&self, locale: &Locale) -> String {
227        self.try_render_hidden(locale)
228            .unwrap_or_else(|| self.hidden_fallback.clone())
229    }
230
231    fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
232        for locale_key in self.locale_candidates(locale) {
233            if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
234            {
235                return Some(rendered);
236            }
237        }
238        None
239    }
240
241    fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
242        let mut candidates = Vec::new();
243        let fallback = self.fallback_locale.as_deref();
244        let ordered_candidates = [
245            Some(locale.as_str()),
246            locale
247                .as_str()
248                .split_once('-')
249                .map(|(language, _)| language),
250            fallback,
251            fallback.and_then(|value| value.split_once('-').map(|(language, _)| language)),
252        ];
253        for candidate in ordered_candidates.into_iter().flatten() {
254            push_locale_candidate(&mut candidates, candidate);
255        }
256
257        candidates
258    }
259
260    fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
261        let args = fluent_args(reason);
262        self.render_message(reason.code.as_str(), Some(&args), locale_key)
263    }
264
265    fn render_message(
266        &self,
267        message_id: &str,
268        args: Option<&FluentArgs<'_>>,
269        locale_key: &str,
270    ) -> Option<String> {
271        let bundle = self.bundles.get(locale_key)?;
272        let message = bundle.get_message(message_id)?;
273        let pattern = message.value()?;
274        let mut errors = Vec::new();
275        let rendered = bundle.format_pattern(pattern, args, &mut errors);
276        errors.is_empty().then(|| rendered.into_owned())
277    }
278}
279
280impl ReasonCatalog for FluentCatalog {
281    fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
282        self.render_reason(reason, locale)
283    }
284}
285
286/// Errors returned while building a [`FluentCatalog`].
287#[derive(Debug, Error, PartialEq)]
288pub enum FluentCatalogError {
289    /// The locale could not be parsed by `unic-langid`.
290    #[error("invalid fluent locale {locale}: {source}")]
291    InvalidLocale {
292        /// Rejected locale string.
293        locale: String,
294        /// Parser error returned by `unic-langid`.
295        source: LanguageIdentifierError,
296    },
297    /// FTL source could not be parsed.
298    #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
299    Parse {
300        /// Locale the resource was intended for.
301        locale: String,
302        /// Parse errors reported by Fluent.
303        errors: Vec<String>,
304    },
305    /// Fluent rejected the parsed resource.
306    #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
307    Resource {
308        /// Locale the resource was intended for.
309        locale: String,
310        /// Resource errors reported by Fluent.
311        errors: Vec<String>,
312    },
313    /// Hidden-denial generic message id was blank.
314    #[error("{field} must not be empty")]
315    EmptyMessageId {
316        /// Name of the message-id field.
317        field: &'static str,
318    },
319    /// Hidden-denial fallback message was blank.
320    #[error("{field} must not be empty")]
321    EmptyFallbackMessage {
322        /// Name of the fallback-message field.
323        field: &'static str,
324    },
325}
326
327fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
328    locale
329        .parse::<LanguageIdentifier>()
330        .map_err(|source| FluentCatalogError::InvalidLocale {
331            locale: locale.to_owned(),
332            source,
333        })
334}
335
336fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
337    FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
338        locale: locale.to_owned(),
339        errors: errors.into_iter().map(|error| error.to_string()).collect(),
340    })
341}
342
343fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
344    errors.into_iter().map(|error| error.to_string()).collect()
345}
346
347fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
348    if value.trim().is_empty() {
349        Err(FluentCatalogError::EmptyMessageId {
350            field: "hidden message id",
351        })
352    } else {
353        Ok(value)
354    }
355}
356
357fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
358    if value.trim().is_empty() {
359        Err(FluentCatalogError::EmptyFallbackMessage {
360            field: "hidden fallback message",
361        })
362    } else {
363        Ok(value)
364    }
365}
366
367fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
368    if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
369        let candidate = locale.to_string();
370        if !candidates.contains(&candidate) {
371            candidates.push(candidate);
372        }
373    }
374}
375
376fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
377    let mut args = FluentArgs::with_capacity(reason.params.len());
378    for (key, value) in &reason.params {
379        args.set(key.as_str().to_owned(), fluent_value(value));
380    }
381    args
382}
383
384fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
385    match value {
386        ReasonValue::Str(value) => FluentValue::from(value.clone()),
387        ReasonValue::Int(value) => FluentValue::from(*value),
388        ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
389        ReasonValue::Outcome(value) => json_value(value),
390    }
391}
392
393fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
394    match value {
395        serde_json::Value::Null => FluentValue::from("null"),
396        serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
397        serde_json::Value::Number(value) => number_value(value),
398        serde_json::Value::String(value) => FluentValue::from(value.clone()),
399        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
400            FluentValue::from(value.to_string())
401        }
402    }
403}
404
405fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
406    value
407        .as_i64()
408        .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
409}
410
411fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
412    value.as_u64().map_or_else(
413        || float_value(value),
414        |value| {
415            i64::try_from(value)
416                .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
417        },
418    )
419}
420
421fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
422    value
423        .as_f64()
424        .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
425}