Skip to main content

sioul_core/
lines.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Content lines, the text format vCard (RFC 6350 §3) and iCalendar (RFC 5545
5//! §3.1) share: `NAME;PARAM=VALUE:value`, folded at 75 octets.
6//!
7//! Sioul changes cards and events line by line: the lines it edits are
8//! replaced, every other line is kept byte for byte, so a photo, another
9//! application's fields or a time zone it does not show come back exactly as
10//! they were.
11
12/// The logical lines of a text, folded lines joined again (a line starting
13/// with a space or a tab continues the one before).
14pub fn unfold(text: &str) -> Vec<String> {
15    let mut lines: Vec<String> = Vec::new();
16    for raw in text.split('\n') {
17        let line = raw.strip_suffix('\r').unwrap_or(raw);
18        match line.strip_prefix([' ', '\t']) {
19            Some(rest) if !lines.is_empty() => lines.last_mut().into_iter().for_each(|last| last.push_str(rest)),
20            _ if line.is_empty() => {}
21            _ => lines.push(line.to_string()),
22        }
23    }
24    lines
25}
26
27/// Lines back into text: CRLF, folded at 75 octets without cutting a character.
28pub fn fold(lines: &[String]) -> String {
29    let mut out = String::new();
30    for line in lines {
31        let mut width = 0;
32        for c in line.chars() {
33            let size = c.len_utf8();
34            if width + size > 75 {
35                out.push_str("\r\n ");
36                width = 1;
37            }
38            out.push(c);
39            width += size;
40        }
41        out.push_str("\r\n");
42    }
43    out
44}
45
46/// A line's property name, uppercase, without its group: "item1.EMAIL;TYPE=…" → "EMAIL".
47pub fn name(line: &str) -> String {
48    let head = line.split([';', ':']).next().unwrap_or("");
49    head.rsplit('.').next().unwrap_or(head).to_ascii_uppercase()
50}
51
52/// Where a line's value starts: its first colon outside quotes.
53fn colon(line: &str) -> Option<usize> {
54    let mut quoted = false;
55    for (i, c) in line.char_indices() {
56        match c {
57            '"' => quoted = !quoted,
58            ':' if !quoted => return Some(i),
59            _ => {}
60        }
61    }
62    None
63}
64
65/// A line's value, after the first colon outside quotes.
66pub fn value(line: &str) -> &str {
67    colon(line).map_or("", |i| &line[i + 1..])
68}
69
70/// A line's parameters, in order: names uppercase, values without their
71/// quotes ("ALTREP=\"sioul:contact/x\"" → ("ALTREP", "sioul:contact/x")); a
72/// parameter without "=" comes with an empty value.
73pub fn params(line: &str) -> Vec<(String, String)> {
74    // A line without its colon (from a server or a mail) is all head.
75    let head = colon(line).map_or(line, |i| &line[..i]);
76    let mut parts = Vec::new();
77    let mut quoted = false;
78    let mut start = 0;
79    for (i, c) in head.char_indices() {
80        match c {
81            '"' => quoted = !quoted,
82            ';' if !quoted => {
83                parts.push(&head[start..i]);
84                start = i + 1;
85            }
86            _ => {}
87        }
88    }
89    parts.push(&head[start..]);
90    parts
91        .iter()
92        .skip(1)
93        .map(|p| match p.split_once('=') {
94            Some((name, value)) => (name.trim().to_ascii_uppercase(), caret_decode(value.trim().trim_matches('"'))),
95            None => (p.trim().to_ascii_uppercase(), String::new()),
96        })
97        .collect()
98}
99
100/// One parameter's value, the first time it is given.
101pub fn param(line: &str, name: &str) -> Option<String> {
102    params(line).into_iter().find(|(n, _)| n == name).map(|(_, v)| v)
103}
104
105/// A parameter value as written: quoted when it holds what would end it
106/// (RFC 5545 §3.2); quotes and line breaks in RFC 6868's caret form.
107pub fn param_value(value: &str) -> String {
108    let clean = value.replace('^', "^^").replace('"', "^'").replace("\r\n", "^n").replace(['\n', '\r'], "^n");
109    // Other control characters have no place in a parameter (RFC 5545 §3.1).
110    let clean: String = clean.chars().filter(|c| !c.is_control() || *c == '\t').collect();
111    if clean.contains([':', ';', ',']) { format!("\"{clean}\"") } else { clean }
112}
113
114/// RFC 6868: "^'" is a quote, "^n" a line break, "^^" a caret.
115fn caret_decode(value: &str) -> String {
116    let mut out = String::with_capacity(value.len());
117    let mut chars = value.chars();
118    while let Some(c) = chars.next() {
119        if c != '^' {
120            out.push(c);
121            continue;
122        }
123        match chars.next() {
124            Some('n') => out.push('\n'),
125            Some('\'') => out.push('"'),
126            Some('^') => out.push('^'),
127            Some(other) => {
128                out.push('^');
129                out.push(other);
130            }
131            None => out.push('^'),
132        }
133    }
134    out
135}
136
137/// A text value, escaped (RFC 6350 §3.4, RFC 5545 §3.3.11). A line break,
138/// CR alone too, is "\n": nothing written here can start a line of its own;
139/// other control characters, which text may not hold, are left out.
140pub fn escape(text: &str) -> String {
141    let text: String = text.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\r' | '\t')).collect();
142    text.replace('\\', "\\\\").replace(';', "\\;").replace(',', "\\,").replace("\r\n", "\\n").replace(['\n', '\r'], "\\n")
143}
144
145/// A text value as written, unescaped.
146pub fn unescape(text: &str) -> String {
147    let mut out = String::with_capacity(text.len());
148    let mut chars = text.chars();
149    while let Some(c) = chars.next() {
150        if c != '\\' {
151            out.push(c);
152            continue;
153        }
154        match chars.next() {
155            Some('n' | 'N') => out.push('\n'),
156            Some(other) => out.push(other),
157            None => out.push('\\'),
158        }
159    }
160    out
161}
162
163/// The parts of a structured value (N, ADR), each escaped, joined by semicolons.
164pub fn structured(parts: &[String]) -> String {
165    parts.iter().map(|p| escape(p)).collect::<Vec<_>>().join(";")
166}
167
168const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
169
170/// RFC 4648 base64, as Autocrypt headers and Basic authentication carry it.
171pub fn base64_encode(bytes: &[u8]) -> String {
172    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
173    for chunk in bytes.chunks(3) {
174        let n = (u32::from(chunk[0]) << 16) | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) | u32::from(*chunk.get(2).unwrap_or(&0));
175        for i in 0..4 {
176            if i <= chunk.len() {
177                out.push(BASE64[((n >> (18 - 6 * i)) & 63) as usize] as char);
178            } else {
179                out.push('=');
180            }
181        }
182    }
183    out
184}
185
186/// Base64 back into bytes; spaces and line breaks are skipped. None when it is not base64.
187pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
188    let mut out = Vec::with_capacity(text.len() * 3 / 4);
189    let (mut buffer, mut bits) = (0u32, 0u32);
190    for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) {
191        if c == b'=' {
192            break;
193        }
194        let value = BASE64.iter().position(|&b| b == c)? as u32;
195        buffer = (buffer << 6) | value;
196        bits += 6;
197        if bits >= 8 {
198            bits -= 8;
199            out.push((buffer >> bits) as u8);
200            buffer &= (1 << bits) - 1;
201        }
202    }
203    Some(out)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn folds_and_unfolds() {
212        let long = format!("NOTE:{}", "é".repeat(60));
213        let text = fold(&[long.clone(), "FN:A".into()]);
214        assert!(text.lines().all(|l| l.len() <= 76), "{text}");
215        assert_eq!(unfold(&text), vec![long, "FN:A".to_string()]);
216        assert_eq!(name("item1.EMAIL;TYPE=work:a@example.org"), "EMAIL");
217        assert_eq!(value("ATTENDEE;CN=\"Doe: Jane\":mailto:jane@example.org"), "mailto:jane@example.org");
218        assert_eq!(params("CONTACT;ALTREP=\"sioul:contact/a;b\";LANGUAGE=fr:Jane"), vec![("ALTREP".to_string(), "sioul:contact/a;b".to_string()), ("LANGUAGE".to_string(), "fr".to_string())]);
219        assert_eq!(param("RELATED-TO;reltype=DEPENDS-ON:x", "RELTYPE").as_deref(), Some("DEPENDS-ON"));
220        assert_eq!((param_value("Venue"), param_value("Letter: 2")), ("Venue".to_string(), "\"Letter: 2\"".to_string()));
221        assert_eq!(param(&format!("LINK;LABEL={}:x", param_value("The \"letter\"; 2^")), "LABEL").as_deref(), Some("The \"letter\"; 2^"));
222        assert_eq!(unescape(&escape("a, b; c\\d\ne")), "a, b; c\\d\ne");
223        assert_eq!(base64_encode(b"jane:pass word"), "amFuZTpwYXNzIHdvcmQ=");
224        assert_eq!(base64_decode("amFuZTpwYXNz\r\n IHdvcmQ=").unwrap(), b"jane:pass word");
225    }
226
227    #[test]
228    fn odd_lines_from_elsewhere() {
229        // No colon, a character of two bytes last: all head, no panic.
230        assert_eq!(params("X-FOO;A=é"), vec![("A".to_string(), "é".to_string())]);
231        assert_eq!(value("X-FOO;A=é"), "");
232        // A CR alone cannot start a line of its own.
233        let title = escape("Crash\rSTATUS:COMPLETED\u{7}");
234        assert!(!title.contains(['\r', '\n', '\u{7}']), "{title}");
235        assert_eq!(unescape(&title), "Crash\nSTATUS:COMPLETED");
236        assert!(!param_value("a\rb").contains('\r'));
237    }
238}