Skip to main content

eml_codec/text/
quoted.rs

1#[cfg(feature = "arbitrary")]
2use arbitrary::Arbitrary;
3use bounded_static::ToStatic;
4use nom::{
5    branch::alt,
6    bytes::complete::{tag, take, take_while1},
7    combinator::{map, opt, verify},
8    multi::many0,
9    sequence::{delimited, pair, preceded},
10    IResult,
11};
12use std::borrow::Cow;
13use std::fmt;
14#[cfg(feature = "arbitrary")]
15use std::ops::ControlFlow;
16#[cfg(feature = "tracing")]
17use tracing::warn;
18
19use crate::i18n::ContainsUtf8;
20use crate::print::{Formatter, Print, ToStringFromPrint};
21use crate::text::ascii;
22use crate::text::utf8::{is_nonascii_or, take_utf8_while1};
23use crate::text::whitespace::{cfws, fws, is_obs_no_ws_ctl};
24use crate::text::words::is_vchar;
25#[cfg(feature = "tracing-recover")]
26use crate::utils::bytes_to_trace_string;
27#[cfg(feature = "arbitrary")]
28use crate::{arbitrary_utils::arbitrary_string_where, fuzz_eq::FuzzEq};
29use eml_codec_derives::instrument_input;
30
31// A quoted string contains bytes that satisfy `is_vchar` or are in `ascii::WS`.
32#[derive(Clone, ContainsUtf8, PartialEq, Default, ToStatic, ToStringFromPrint)]
33pub struct QuotedString<'a>(pub Vec<Cow<'a, str>>);
34
35impl<'a> fmt::Debug for QuotedString<'a> {
36    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
37        fmt.debug_tuple("QuotedString")
38            .field(&self.0.iter().collect::<Vec<_>>())
39            .finish()
40    }
41}
42
43impl<'a> QuotedString<'a> {
44    pub fn push_str(&mut self, e: &'a str) {
45        self.0.push(Cow::Borrowed(e))
46    }
47
48    pub fn push(&mut self, e: Cow<'a, str>) {
49        self.0.push(e)
50    }
51
52    pub fn chars<'b>(&'b self) -> QuotedStringChars<'a, 'b> {
53        QuotedStringChars {
54            q: self,
55            inner: QuotedStringCharsInner::NextFragment(0),
56        }
57    }
58}
59impl<'a> Print for QuotedString<'a> {
60    fn print(&self, fmt: &mut impl Formatter) {
61        print_quoted(fmt, self.chars())
62    }
63}
64impl<'a> TryFrom<&'a str> for QuotedString<'a> {
65    type Error = (); // TODO
66    fn try_from(s: &'a str) -> Result<Self, Self::Error> {
67        if s.chars().all(is_strict_quoted_pair) {
68            Ok(Self(vec![s.into()]))
69        } else {
70            Err(())
71        }
72    }
73}
74
75#[cfg(feature = "arbitrary")]
76impl<'a> Arbitrary<'a> for QuotedString<'a> {
77    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
78        let mut chunks = Vec::new();
79        u.arbitrary_loop(None, Some(10), |u| {
80            let bytes = arbitrary_string_where(u, |c| is_vchar(c) || ascii::WS_CHAR.contains(&c))?;
81            chunks.push(Cow::Owned(bytes));
82            Ok(ControlFlow::Continue(()))
83        })?;
84        Ok(QuotedString(chunks))
85    }
86}
87
88#[cfg(feature = "arbitrary")]
89impl<'a> FuzzEq for QuotedString<'a> {
90    fn fuzz_eq(&self, other: &Self) -> bool {
91        self.chars().collect::<String>() == other.chars().collect::<String>()
92    }
93}
94
95#[derive(Clone)]
96pub struct QuotedStringChars<'a, 'b> {
97    q: &'b QuotedString<'a>,
98    inner: QuotedStringCharsInner<'b>,
99}
100#[derive(Clone)]
101enum QuotedStringCharsInner<'a> {
102    NextFragment(usize),
103    FragmentChars(usize, std::str::Chars<'a>),
104}
105
106impl<'a, 'b> Iterator for QuotedStringChars<'a, 'b> {
107    type Item = char;
108    fn next(&mut self) -> Option<Self::Item> {
109        match &mut self.inner {
110            QuotedStringCharsInner::NextFragment(idx) => match self.q.0.get(*idx) {
111                Some(frag) => {
112                    self.inner = QuotedStringCharsInner::FragmentChars(*idx, frag.chars());
113                    self.next()
114                }
115                None => None,
116            },
117            QuotedStringCharsInner::FragmentChars(idx, it) => match it.next() {
118                Some(c) => Some(c),
119                None => {
120                    self.inner = QuotedStringCharsInner::NextFragment(*idx + 1);
121                    self.next()
122                }
123            },
124        }
125    }
126}
127
128/// Quoted pair
129///
130/// ```abnf
131///    quoted-pair     =   ("\" (VCHAR / WSP)) / obs-qp
132///    obs-qp          =   "\" (%d0 / obs-NO-WS-CTL / LF / CR)
133/// ```
134/// We parse quoted pairs even more liberally, allowing any ASCII byte after
135/// the backslash.
136///
137/// However, we only return `Some(_)` for quoted pairs that are valid
138/// according to the strict syntax; other quoted pairs cannot be printed
139/// back and we chose to ignore them.
140pub fn quoted_pair(input: &[u8]) -> IResult<&[u8], Option<&str>> {
141    preceded(
142        tag(&[ascii::BACKSLASH]),
143        map(
144            verify(take(1usize), |b: &[u8]| b[0].is_ascii()),
145            |s: &[u8]| {
146                let b = s[0];
147                if is_strict_quoted_pair(b.into()) {
148                    // SAFETY: from the combinators above (take and verify), we
149                    // know that `b` contains a single ASCII character.
150                    Some(unsafe { str::from_utf8_unchecked(s) })
151                } else {
152                    if !(b == ascii::NULL
153                        || is_obs_no_ws_ctl(b)
154                        || b == ascii::LF
155                        || b == ascii::CR)
156                    {
157                        #[cfg(feature = "tracing-recover")]
158                        warn!(byte = %bytes_to_trace_string(&[b]),
159                                  "invalid quoted pair")
160                    }
161                    None
162                }
163            },
164        ),
165    )(input)
166}
167
168fn is_strict_quoted_pair(c: char) -> bool {
169    is_vchar(c) || ascii::WS_CHAR.contains(&c)
170}
171
172/// Allowed characters in quote
173///
174/// ```abnf
175///   qtext           =   %d33 /             ; Printable US-ASCII
176///                       %d35-91 /          ;  characters not including
177///                       %d93-126 /         ;  "\" or the quote character
178///                       obs-qtext
179/// ```
180/// following RFC6532, also allows non-ascii UTF-8
181fn is_strict_qtext(c: char) -> bool {
182    is_nonascii_or(|c| {
183        c == ascii::EXCLAMATION
184            || (ascii::NUM..=ascii::LEFT_BRACKET).contains(&c)
185            || (ascii::RIGHT_BRACKET..=ascii::TILDE).contains(&c)
186    })(c)
187}
188
189fn is_obs_qtext(c: u8) -> bool {
190    is_obs_no_ws_ctl(c)
191}
192
193/// Quoted pair content
194///
195/// ```abnf
196///   qcontent        =   qtext / quoted-pair
197/// ```
198///
199/// Like for `quoted_pair`, this supports the obsolete syntax but
200/// returns `None` in this case.
201#[instrument_input("tracing")]
202fn qcontent(input: &[u8]) -> IResult<&[u8], Option<Cow<'_, str>>> {
203    alt((
204        map(take_utf8_while1(is_strict_qtext), Some),
205        map(take_while1(is_obs_qtext), |_| None),
206        map(quoted_pair, |qp| qp.map(Cow::Borrowed)),
207    ))(input)
208}
209
210/// Quoted string
211///
212/// ```abnf
213/// quoted-string   =   [CFWS]
214///                     DQUOTE *([FWS] qcontent) [FWS] DQUOTE
215///                     [CFWS]
216/// ```
217#[instrument_input("tracing")]
218pub fn quoted_string(input: &[u8]) -> IResult<&[u8], QuotedString<'_>> {
219    delimited(opt(cfws), quoted_string_plain, opt(cfws))(input)
220}
221pub fn quoted_string_plain(input: &[u8]) -> IResult<&[u8], QuotedString<'_>> {
222    let (input, _) = tag("\"")(input)?;
223    let (input, content) = many0(pair(opt(fws), qcontent))(input)?;
224    let (input, maybe_wsp) = opt(fws)(input)?;
225    let (input, _) = tag("\"")(input)?;
226
227    // Rebuild string
228    let mut qstring =
229        content
230            .into_iter()
231            .fold(QuotedString::default(), |mut acc, (maybe_wsp, c)| {
232                for wsp in maybe_wsp.into_iter().flat_map(|v| v.into_iter()) {
233                    acc.push_str(wsp);
234                }
235                if let Some(c) = c {
236                    acc.push(c);
237                }
238                acc
239            });
240
241    for wsp in maybe_wsp.into_iter().flat_map(|v| v.into_iter()) {
242        qstring.push_str(wsp);
243    }
244
245    Ok((input, qstring))
246}
247
248pub fn print_quoted<I>(fmt: &mut impl Formatter, data: I)
249where
250    I: IntoIterator<Item = char>,
251{
252    let mut buf = [0u8; 4];
253    fmt.write_bytes(b"\"");
254    for c in data.into_iter() {
255        let b = c.encode_utf8(&mut buf).as_bytes();
256        if is_strict_qtext(c) {
257            fmt.write_bytes(b);
258        } else if ascii::WS_CHAR.contains(&c) {
259            // NOTE: we can either output the whitespace as folding
260            // whitespace or to escape it; we choose to output it as folding
261            // whitespace which helps performing line folding.
262            fmt.write_fws_bytes(b);
263        } else if is_vchar(c) {
264            fmt.write_bytes(b"\\");
265            fmt.write_bytes(b);
266        } else {
267            // RFC5322 does not allow escaping bytes other than VCHAR in
268            // quoted strings. We drop them.
269            // NOTE: this case shouldn't happen in practice, because
270            // non-displayable quoted pairs are already dropped during
271            // parsing...
272            // TODO: return the invalid input bytes that were skipped.
273        }
274    }
275    fmt.write_bytes(b"\"")
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::print::tests::print_to_vec_with;
282
283    #[test]
284    fn test_quoted_string_parser() {
285        assert_eq!(
286            quoted_string(b" \"hello\\\"world\" ").unwrap().1,
287            QuotedString(vec!["hello".into(), "\"".into(), "world".into(),])
288        );
289
290        assert_eq!(
291            quoted_string(b"\"hello\r\n world\""),
292            Ok((
293                &b""[..],
294                QuotedString(vec!["hello".into(), " ".into(), "world".into(),])
295            )),
296        );
297
298        assert_eq!(
299            quoted_string(b"\"\t\""),
300            Ok((&b""[..], QuotedString(vec!["\t".into(),]))),
301        );
302    }
303
304    #[test]
305    fn test_quoted_string_printer() {
306        let out = print_to_vec_with(|f| {
307            print_quoted(
308                f,
309                QuotedString(vec!["hello".into(), "\"".into(), " world".into()]).chars(),
310            );
311        });
312        assert_eq!(out, b"\"hello\\\" world\"");
313    }
314
315    #[test]
316    fn test_quoted_string_object() {
317        assert_eq!(
318            QuotedString(vec!["hello".into(), " ".into(), "world".into(),]).to_string(),
319            "\"hello world\"".to_string(),
320        );
321    }
322}