From 2b645f7be4d0888bb1f6c738f3c571e95f83cf48 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 3 Oct 2023 09:43:58 +0200 Subject: [PATCH] templates: escape translation placeholders --- crates/i18n/src/sprintf/formatter.rs | 39 ++++++++++++++++++++++++++-- crates/i18n/src/sprintf/mod.rs | 5 ++-- crates/templates/Cargo.toml | 2 +- crates/templates/src/functions.rs | 24 ++++++++++++++--- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/crates/i18n/src/sprintf/formatter.rs b/crates/i18n/src/sprintf/formatter.rs index e369b7e5..259f22cd 100644 --- a/crates/i18n/src/sprintf/formatter.rs +++ b/crates/i18n/src/sprintf/formatter.rs @@ -484,11 +484,22 @@ fn format_value(value: &Value, placeholder: &Placeholder) -> Result { +pub enum FormattedMessagePart<'a> { + /// A literal text part of the message. It should not be escaped. Text(&'a str), + /// A placeholder part of the message. It should be escaped. Placeholder(String), } +impl<'a> FormattedMessagePart<'a> { + fn len(&self) -> usize { + match self { + FormattedMessagePart::Text(text) => text.len(), + FormattedMessagePart::Placeholder(placeholder) => placeholder.len(), + } + } +} + impl std::fmt::Display for FormattedMessagePart<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -500,6 +511,27 @@ impl std::fmt::Display for FormattedMessagePart<'_> { pub struct FormattedMessage<'a> { parts: Vec>, + total_len: usize, +} + +impl FormattedMessage<'_> { + /// Returns the length of the formatted message (not the number of parts). + #[must_use] + pub fn len(&self) -> usize { + self.total_len + } + + /// Returns `true` if the formatted message is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.total_len == 0 + } + + /// Returns the list of parts of the formatted message. + #[must_use] + pub fn parts(&self) -> &[FormattedMessagePart<'_>] { + &self.parts + } } impl std::fmt::Display for FormattedMessage<'_> { @@ -529,6 +561,8 @@ impl Message { // Holds the current index of the placeholder we are formatting, which is used // by non-named, non-indexed placeholders let mut current_placeholder = 0usize; + // Compute the total length of the formatted message + let mut total_len = 0usize; for part in self.parts() { let formatted = match part { Part::Percent => FormattedMessagePart::Text("%"), @@ -563,9 +597,10 @@ impl Message { FormattedMessagePart::Placeholder(formatted) } }; + total_len += formatted.len(); parts.push(formatted); } - Ok(FormattedMessage { parts }) + Ok(FormattedMessage { parts, total_len }) } } diff --git a/crates/i18n/src/sprintf/mod.rs b/crates/i18n/src/sprintf/mod.rs index f9b1d8f1..20a07f78 100644 --- a/crates/i18n/src/sprintf/mod.rs +++ b/crates/i18n/src/sprintf/mod.rs @@ -19,10 +19,9 @@ mod formatter; mod message; mod parser; -use thiserror::Error; - pub use self::{ argument::{Argument, List as ArgumentList}, + formatter::{FormatError, FormattedMessage, FormattedMessagePart}, message::Message, }; @@ -79,7 +78,7 @@ pub(crate) use arg_list_inner; #[allow(unused_imports)] pub(crate) use sprintf; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] #[error(transparent)] enum Error { Format(#[from] self::formatter::FormatError), diff --git a/crates/templates/Cargo.toml b/crates/templates/Cargo.toml index a16e5221..cb798c14 100644 --- a/crates/templates/Cargo.toml +++ b/crates/templates/Cargo.toml @@ -16,7 +16,7 @@ walkdir = "2.4.0" anyhow.workspace = true thiserror.workspace = true -minijinja = { workspace = true, features = ["loader", "json", "speedups"] } +minijinja = { workspace = true, features = ["loader", "json", "speedups", "unstable_machinery"] } serde.workspace = true serde_json.workspace = true serde_urlencoded = "0.7.1" diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index 219c5483..edafcaf6 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -25,10 +25,12 @@ use std::{ }; use camino::Utf8Path; -use mas_i18n::{Argument, ArgumentList, DataLocale, Translator}; +use mas_i18n::{sprintf::FormattedMessagePart, Argument, ArgumentList, DataLocale, Translator}; use mas_router::UrlBuilder; use mas_spa::ViteManifest; use minijinja::{ + escape_formatter, + machinery::make_string_output, value::{from_args, Kwargs, Object, SeqObject, ViaDeserialize}, Error, ErrorKind, State, Value, }; @@ -258,12 +260,26 @@ impl Object for Translate { .collect(); let list = res?; - let formatted = message.format(&list).map_err(|e| { + let formatted = message.format_(&list).map_err(|e| { Error::new(ErrorKind::InvalidOperation, "Could not format message").with_source(e) })?; - // TODO: escape - Ok(Value::from_safe_string(formatted)) + let mut buf = String::with_capacity(formatted.len()); + let mut output = make_string_output(&mut buf); + for part in formatted.parts() { + match part { + FormattedMessagePart::Text(text) => { + // Literal text, just write it + output.write_str(text)?; + } + FormattedMessagePart::Placeholder(placeholder) => { + // Placeholder, escape it + escape_formatter(&mut output, state, &placeholder.as_str().into())?; + } + } + } + + Ok(Value::from_safe_string(buf)) } }