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
192        for locale_key in self.locale_candidates(locale) {
193            if let Some(rendered) = self.render_from_bundle(reason, &locale_key) {
194                return Some(rendered);
195            }
196        }
197        None
198    }
199
200    fn render_hidden(&self, locale: &Locale) -> String {
201        self.try_render_hidden(locale)
202            .unwrap_or_else(|| self.hidden_fallback.clone())
203    }
204
205    fn try_render_hidden(&self, locale: &Locale) -> Option<String> {
206        for locale_key in self.locale_candidates(locale) {
207            if let Some(rendered) = self.render_message(&self.hidden_message_id, None, &locale_key)
208            {
209                return Some(rendered);
210            }
211        }
212        None
213    }
214
215    fn locale_candidates(&self, locale: &Locale) -> Vec<String> {
216        let mut candidates = Vec::new();
217        let fallback = self.fallback_locale.as_deref();
218        let ordered_candidates = [
219            Some(locale.as_str()),
220            locale
221                .as_str()
222                .split_once('-')
223                .map(|(language, _)| language),
224            fallback,
225            fallback.and_then(|value| value.split_once('-').map(|(language, _)| language)),
226        ];
227        for candidate in ordered_candidates.into_iter().flatten() {
228            push_locale_candidate(&mut candidates, candidate);
229        }
230
231        candidates
232    }
233
234    fn render_from_bundle(&self, reason: &DenialReason, locale_key: &str) -> Option<String> {
235        let args = fluent_args(reason);
236        self.render_message(reason.code.as_str(), Some(&args), locale_key)
237    }
238
239    fn render_message(
240        &self,
241        message_id: &str,
242        args: Option<&FluentArgs<'_>>,
243        locale_key: &str,
244    ) -> Option<String> {
245        let bundle = self.bundles.get(locale_key)?;
246        let message = bundle.get_message(message_id)?;
247        let pattern = message.value()?;
248        let mut errors = Vec::new();
249        let rendered = bundle.format_pattern(pattern, args, &mut errors);
250        errors.is_empty().then(|| rendered.into_owned())
251    }
252}
253
254impl ReasonCatalog for FluentCatalog {
255    fn render(&self, reason: &DenialReason, locale: &Locale) -> String {
256        self.render_reason(reason, locale)
257    }
258}
259
260/// Errors returned while building a [`FluentCatalog`].
261#[derive(Debug, Error, PartialEq)]
262pub enum FluentCatalogError {
263    /// The locale could not be parsed by `unic-langid`.
264    #[error("invalid fluent locale {locale}: {source}")]
265    InvalidLocale {
266        /// Rejected locale string.
267        locale: String,
268        /// Parser error returned by `unic-langid`.
269        source: LanguageIdentifierError,
270    },
271    /// FTL source could not be parsed.
272    #[error("failed to parse fluent resource for {locale}: {}", errors.join("; "))]
273    Parse {
274        /// Locale the resource was intended for.
275        locale: String,
276        /// Parse errors reported by Fluent.
277        errors: Vec<String>,
278    },
279    /// Fluent rejected the parsed resource.
280    #[error("failed to add fluent resource for {locale}: {}", errors.join("; "))]
281    Resource {
282        /// Locale the resource was intended for.
283        locale: String,
284        /// Resource errors reported by Fluent.
285        errors: Vec<String>,
286    },
287    /// Hidden-denial generic message id was blank.
288    #[error("{field} must not be empty")]
289    EmptyMessageId {
290        /// Name of the message-id field.
291        field: &'static str,
292    },
293    /// Hidden-denial fallback message was blank.
294    #[error("{field} must not be empty")]
295    EmptyFallbackMessage {
296        /// Name of the fallback-message field.
297        field: &'static str,
298    },
299}
300
301fn parse_locale(locale: &str) -> Result<LanguageIdentifier, FluentCatalogError> {
302    locale
303        .parse::<LanguageIdentifier>()
304        .map_err(|source| FluentCatalogError::InvalidLocale {
305            locale: locale.to_owned(),
306            source,
307        })
308}
309
310fn parse_resource(locale: &str, source: String) -> Result<FluentResource, FluentCatalogError> {
311    FluentResource::try_new(source).map_err(|(_resource, errors)| FluentCatalogError::Parse {
312        locale: locale.to_owned(),
313        errors: errors.into_iter().map(|error| error.to_string()).collect(),
314    })
315}
316
317fn display_errors(errors: Vec<FluentError>) -> Vec<String> {
318    errors.into_iter().map(|error| error.to_string()).collect()
319}
320
321fn validate_message_id(value: String) -> Result<String, FluentCatalogError> {
322    if value.trim().is_empty() {
323        Err(FluentCatalogError::EmptyMessageId {
324            field: "hidden message id",
325        })
326    } else {
327        Ok(value)
328    }
329}
330
331fn validate_hidden_fallback(value: String) -> Result<String, FluentCatalogError> {
332    if value.trim().is_empty() {
333        Err(FluentCatalogError::EmptyFallbackMessage {
334            field: "hidden fallback message",
335        })
336    } else {
337        Ok(value)
338    }
339}
340
341fn push_locale_candidate(candidates: &mut Vec<String>, locale: &str) {
342    if let Ok(locale) = locale.parse::<LanguageIdentifier>() {
343        let candidate = locale.to_string();
344        if !candidates.contains(&candidate) {
345            candidates.push(candidate);
346        }
347    }
348}
349
350fn fluent_args(reason: &DenialReason) -> FluentArgs<'static> {
351    let mut args = FluentArgs::with_capacity(reason.params.len());
352    for (key, value) in &reason.params {
353        args.set(key.as_str().to_owned(), fluent_value(value));
354    }
355    args
356}
357
358fn fluent_value(value: &ReasonValue) -> FluentValue<'static> {
359    match value {
360        ReasonValue::Str(value) => FluentValue::from(value.clone()),
361        ReasonValue::Int(value) => FluentValue::from(*value),
362        ReasonValue::Fact(fact) => FluentValue::from(fact.as_str().to_owned()),
363        ReasonValue::Outcome(value) => json_value(value),
364    }
365}
366
367fn json_value(value: &serde_json::Value) -> FluentValue<'static> {
368    match value {
369        serde_json::Value::Null => FluentValue::from("null"),
370        serde_json::Value::Bool(value) => FluentValue::from(value.to_string()),
371        serde_json::Value::Number(value) => number_value(value),
372        serde_json::Value::String(value) => FluentValue::from(value.clone()),
373        serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
374            FluentValue::from(value.to_string())
375        }
376    }
377}
378
379fn number_value(value: &serde_json::Number) -> FluentValue<'static> {
380    value
381        .as_i64()
382        .map_or_else(|| unsigned_or_float_value(value), FluentValue::from)
383}
384
385fn unsigned_or_float_value(value: &serde_json::Number) -> FluentValue<'static> {
386    value.as_u64().map_or_else(
387        || float_value(value),
388        |value| {
389            i64::try_from(value)
390                .map_or_else(|_| FluentValue::from(value.to_string()), FluentValue::from)
391        },
392    )
393}
394
395fn float_value(value: &serde_json::Number) -> FluentValue<'static> {
396    value
397        .as_f64()
398        .map_or_else(|| FluentValue::from(value.to_string()), FluentValue::from)
399}