Skip to main content

ical/tree/codec/
escape.rs

1//! # Escape (write codec)
2//!
3//! Apply the RFC 5545 3.3.11 value escapes when serializing.
4//!
5//! This is the write half of the escaping codec; its inverse is
6//! [`unescape`](crate::tree::codec::unescape), and the version-specific rules
7//! are selected by the [`Escaper`]. The structural encoders in
8//! [`encode`](crate::tree::codec::encode) run every value leaf through here.
9//!
10//! No mode ever writes a byte that would end the line, whatever a caller put
11//! in the value.
12//!
13//! Where a version has no escape for one, as vCalendar 1.0 has none for a
14//! newline, the escape that keeps the calendar readable is written and the
15//! round trip through [`unescape`](crate::tree::codec::unescape) is not exact.
16//!
17//! A parameter value is a different alphabet and has its own writer,
18//! `escape_param`, applying the RFC 6868 caret encoding rather than any
19//! backslash one.
20
21use alloc::{borrow::Cow, string::String, vec::Vec};
22
23use crate::tree::codec::mode::Escaper;
24
25/// Apply the value escapes by the calendar's escaping mode, over raw bytes.
26///
27/// RFC 5545 3.3.11 for the modern rules, `;` and a newline for vCalendar 1.0.
28/// Borrows when nothing needs escaping; non-UTF-8 content passes through
29/// verbatim.
30pub(crate) fn escape_with(bytes: &[u8], escaper: Escaper) -> Cow<'_, [u8]> {
31    match escaper {
32        Escaper::Modern => escape_modern(bytes),
33        Escaper::V1_0 => escape_v21(bytes),
34    }
35}
36
37/// Apply the RFC 6868 3.1 parameter value encoding (a newline as `^n`, a caret
38/// as `^^` and a double quote as `^'`), then wrap the result in the RFC 5545
39/// 3.1 delimiters when it needs them. The inverse of
40/// [`unescape_param`](crate::tree::codec::unescape::unescape_param).
41///
42/// A version predating RFC 6868 is written with no parameter encoding at all,
43/// and one predating the `quoted-string` production with no quoting: see
44/// [`Escaper::has_param_encoding`] and [`Escaper::has_param_quoting`].
45pub(crate) fn escape_param(value: &str, escaper: Escaper) -> Cow<'_, str> {
46    let value = match escaper.has_param_encoding() {
47        true => escape_carets(value),
48        false => Cow::Borrowed(value),
49    };
50
51    if !escaper.has_param_quoting() || !value.contains([',', ';', ':']) {
52        return value;
53    }
54
55    // NOTE: a double quote never reaches here, the caret encoding having
56    // spelled it `^'`, so the pair written below is unambiguously the
57    // production's own.
58    let mut out = String::with_capacity(value.len() + 2);
59    out.push('"');
60    out.push_str(&value);
61    out.push('"');
62    Cow::Owned(out)
63}
64
65/// Apply the RFC 5545 3.3.11 value escapes `\\` `\,` `\;` `\n`.
66fn escape_modern(bytes: &[u8]) -> Cow<'_, [u8]> {
67    if !bytes
68        .iter()
69        .any(|b| matches!(b, b'\\' | b',' | b';' | b'\n'))
70    {
71        return Cow::Borrowed(bytes);
72    }
73
74    let mut out = Vec::with_capacity(bytes.len());
75
76    for &b in bytes {
77        match b {
78            b'\\' => out.extend_from_slice(b"\\\\"),
79            b',' => out.extend_from_slice(b"\\,"),
80            b';' => out.extend_from_slice(b"\\;"),
81            b'\n' => out.extend_from_slice(b"\\n"),
82            other => out.push(other),
83        }
84    }
85
86    Cow::Owned(out)
87}
88
89/// Apply the vCalendar 1.0 value escapes: `\;`, plus `\n` for a newline.
90///
91/// Versit has no newline escape, so a newline written into a 1.0 value goes out
92/// as `\n` and reads back as those two characters. That is the closest 1.0 can
93/// carry it; left raw it would end the line and the calendar would not parse.
94fn escape_v21(bytes: &[u8]) -> Cow<'_, [u8]> {
95    if !bytes.iter().any(|b| matches!(b, b';' | b'\n')) {
96        return Cow::Borrowed(bytes);
97    }
98
99    let mut out = Vec::with_capacity(bytes.len() + 2);
100
101    for &b in bytes {
102        match b {
103            b';' => out.extend_from_slice(b"\\;"),
104            b'\n' => out.extend_from_slice(b"\\n"),
105            other => out.push(other),
106        }
107    }
108
109    Cow::Owned(out)
110}
111
112/// Apply the RFC 6868 caret encoding over every character of `value`.
113fn escape_carets(value: &str) -> Cow<'_, str> {
114    if !value.contains(['\n', '^', '"']) {
115        return Cow::Borrowed(value);
116    }
117
118    let mut out = String::with_capacity(value.len());
119
120    for c in value.chars() {
121        match c {
122            '\n' => out.push_str("^n"),
123            '^' => out.push_str("^^"),
124            '"' => out.push_str("^'"),
125            other => out.push(other),
126        }
127    }
128
129    Cow::Owned(out)
130}
131
132#[cfg(test)]
133mod tests {
134    use alloc::borrow::Cow;
135
136    use crate::tree::codec::{
137        escape::{escape_param, escape_with},
138        mode::Escaper,
139    };
140
141    #[test]
142    fn escapes_separators_and_newlines_and_borrows_when_clean() {
143        assert_eq!(
144            escape_with(b"a,b;c\nd", Escaper::Modern).as_ref(),
145            br"a\,b\;c\nd".as_slice(),
146        );
147        assert!(matches!(
148            escape_with(b"plain", Escaper::Modern),
149            Cow::Borrowed(b"plain")
150        ));
151        // NOTE: vCalendar 1.0 escapes `;`, and a newline as `\n` for want of
152        // an escape of its own, which would otherwise end the line.
153        assert_eq!(
154            escape_with(b"a,b;c\nd", Escaper::V1_0).as_ref(),
155            br"a,b\;c\nd".as_slice(),
156        );
157    }
158
159    #[test]
160    fn encodes_the_rfc_6868_parameter_sequences_and_borrows_when_clean() {
161        assert_eq!(escape_param("a\nb^c\"d", Escaper::Modern), "a^nb^^c^'d");
162        assert!(matches!(
163            escape_param("plain", Escaper::Modern),
164            Cow::Borrowed("plain")
165        ));
166        // NOTE: RFC 6868 section 3.2 forbids backslash escaping, so a path
167        // keeps its backslash; its colon is what the quotes are for.
168        assert_eq!(escape_param(r"C:\temp", Escaper::Modern), r#""C:\temp""#);
169    }
170
171    /// RFC 5545 section 3.1 keeps `,`, `;` and `:` out of a bare `paramtext`,
172    /// so a value carrying one is wrapped and a value carrying none is not:
173    /// the quotes are the grammar's, not the value's.
174    #[test]
175    fn quotes_a_parameter_value_only_where_a_delimiter_needs_it() {
176        assert_eq!(
177            escape_param("cid:part1.0001@example.org", Escaper::Modern),
178            "\"cid:part1.0001@example.org\"",
179        );
180        assert_eq!(
181            escape_param("America/New_York", Escaper::Modern),
182            "America/New_York",
183        );
184        assert!(matches!(
185            escape_param("CHAIR", Escaper::Modern),
186            Cow::Borrowed("CHAIR")
187        ));
188    }
189
190    /// A double quote is content, so it goes out RFC 6868 encoded rather than
191    /// as a delimiter, and the pair the comma calls for is added around it.
192    #[test]
193    fn encodes_a_double_quote_rather_than_reading_it_as_a_delimiter() {
194        assert_eq!(
195            escape_param("say \"hi\", then go", Escaper::Modern),
196            "\"say ^'hi^', then go\"",
197        );
198    }
199
200    /// vCalendar 1.0 has no `quoted-string`, so nothing is wrapped: a
201    /// delimiter goes out bare, as every 1.0 writer puts it.
202    #[test]
203    fn never_quotes_a_vcalendar_1_0_parameter_value() {
204        assert!(matches!(
205            escape_param("a,b", Escaper::V1_0),
206            Cow::Borrowed("a,b")
207        ));
208    }
209
210    #[test]
211    fn writes_a_vcalendar_1_0_parameter_unencoded() {
212        // NOTE: RFC 6868 updates RFC 5545 alone, so a 1.0 caret goes out as
213        // itself and a 1.0 reader would not resolve `^^` anyway.
214        assert!(matches!(
215            escape_param("a^b", Escaper::V1_0),
216            Cow::Borrowed("a^b")
217        ));
218    }
219}