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}