1"""Functions for manipulating strings and bytes."""
2
3import re
4
5from icalendar.error import InvalidToken
6from icalendar.parser_tools import DEFAULT_ENCODING, to_unicode
7
8
9def _escape_char(text: str | bytes) -> str:
10 r"""Format value according to iCalendar TEXT escaping rules.
11
12 Escapes special characters in text values according to :rfc:`5545#section-3.3.11`
13 rules.
14 The order of replacements matters to avoid double-escaping.
15
16 Parameters:
17 text: The text to escape.
18
19 Returns:
20 The escaped text with special characters escaped.
21
22 Raises:
23 TypeError: If ``text`` is neither ``str`` nor ``bytes``.
24
25 Note:
26 The replacement order is critical:
27
28 1. ``\N`` -> ``\n`` (normalize newlines to lowercase)
29 2. ``\`` -> ``\\`` (escape backslashes)
30 3. ``;`` -> ``\;`` (escape semicolons)
31 4. ``,`` -> ``\,`` (escape commas)
32 5. ``\r\n`` -> ``\n`` (normalize line endings)
33 6. ``"\n"`` -> ``r"\n"`` (transform a newline character to a literal, or raw,
34 newline character)
35 7. ``"\r"`` -> ``r"\n"`` (transform a lone carriage return to a literal
36 newline character)
37
38 Steps 5 to 7 normalize ``\r\n``, ``\n``, or a lone ``\r`` to ``\n``.
39 The line-ending normalization is an implementation convenience,
40 not part of :rfc:`5545`, which only defines ``\n`` or ``\N`` for an
41 intentional line break, and doesn't give an escape form for a lone ``\r``.
42 """
43 if not isinstance(text, (str, bytes)):
44 raise TypeError(f"Expected str or bytes, got {type(text).__name__}")
45 text = to_unicode(text)
46 # NOTE: ORDER MATTERS!
47 return (
48 text.replace(r"\N", "\n")
49 .replace("\\", "\\\\")
50 .replace(";", r"\;")
51 .replace(",", r"\,")
52 .replace("\r\n", r"\n")
53 .replace("\n", r"\n")
54 .replace("\r", r"\n")
55 )
56
57
58def _unescape_char(text: str | bytes) -> str | bytes:
59 r"""Unescape iCalendar TEXT values.
60
61 Reverses the escaping applied by ``_escape_char`` according to
62 :rfc:`5545#section-3.3.11` TEXT escaping rules.
63
64 Parameters:
65 text: The escaped text.
66
67 Returns:
68 The unescaped text.
69
70 Raises:
71 TypeError: If ``text`` is neither ``str`` nor ``bytes``.
72
73 Note:
74 The replacement order is critical to avoid double-unescaping:
75
76 1. ``\N`` -> ``\n`` (intermediate step)
77 2. ``\r\n`` -> ``\n`` (normalize line endings)
78 3. ``\n`` -> newline (unescape newlines)
79 4. ``\,`` -> ``,`` (unescape commas)
80 5. ``\;`` -> ``;`` (unescape semicolons)
81 6. ``\\`` -> ``\`` (unescape backslashes last)
82 """
83 if not isinstance(text, (str, bytes)):
84 raise TypeError(f"Expected str or bytes, got {type(text).__name__}")
85 # NOTE: ORDER MATTERS!
86 if isinstance(text, str):
87 return (
88 text.replace("\\N", "\\n")
89 .replace("\r\n", "\n")
90 .replace("\\n", "\n")
91 .replace("\\,", ",")
92 .replace("\\;", ";")
93 .replace("\\\\", "\\")
94 )
95 return (
96 text.replace(b"\\N", b"\\n")
97 .replace(b"\r\n", b"\n")
98 .replace(b"\\n", b"\n")
99 .replace(b"\\,", b",")
100 .replace(b"\\;", b";")
101 .replace(b"\\\\", b"\\")
102 )
103
104
105def _foldline(line: str, limit: int = 75, fold_sep: str = "\r\n ") -> str:
106 r"""Make a string folded as defined in :rfc:`5545#section-3.1`.
107
108 Lines of text SHOULD NOT be longer than 75 octets, excluding the line
109 break. Long content lines SHOULD be split into a multiple line
110 representations using a line "folding" technique. That is, a long
111 line can be split between any two characters by inserting a CRLF
112 immediately followed by a single linear white-space character (i.e.,
113 SPACE or HTAB).
114
115 Raises:
116 TypeError: If ``line`` is not a ``str``.
117 ValueError: If ``line`` contains ``\n``.
118
119 """
120 if not isinstance(line, str):
121 raise TypeError(f"Expected str, got {type(line).__name__}")
122 if "\n" in line:
123 raise ValueError("Line must not contain unescaped new line characters.")
124
125 folded_lines: list[str] = []
126 current_chars: list[str] = []
127 byte_count = 0
128 for char in line:
129 char_byte_len = len(char.encode(DEFAULT_ENCODING))
130 if current_chars and byte_count + char_byte_len >= limit:
131 # For compatibility with existing clients, avoid splitting escaped
132 # values such as TEXT backslash escapes or RFC 6868 parameter
133 # escapes across a folded line boundary. See issue #1501.
134 if len(current_chars) > 1 and current_chars[-1] in r"\^":
135 escaped_prefix = current_chars.pop()
136 folded_lines.append("".join(current_chars))
137 current_chars = [escaped_prefix]
138 byte_count = len(escaped_prefix.encode(DEFAULT_ENCODING))
139 else:
140 folded_lines.append("".join(current_chars))
141 current_chars = []
142 byte_count = 0
143 current_chars.append(char)
144 byte_count += char_byte_len
145
146 if current_chars:
147 folded_lines.append("".join(current_chars))
148
149 return fold_sep.join(folded_lines)
150
151
152def _escape_string(val: str) -> str:
153 r"""Escape backslash sequences to URL-encoded hex values.
154
155 Converts backslash-escaped characters to their percent-encoded hex
156 equivalents. This is used for parameter parsing to preserve escaped
157 characters during processing.
158
159 Parameters:
160 val: The string with backslash escapes.
161
162 Returns:
163 The string with backslash escapes converted to percent encoding.
164
165 Note:
166 Conversions:
167
168 - ``%`` -> ``%25``
169 - ``\,`` -> ``%2C``
170 - ``\:`` -> ``%3A``
171 - ``\;`` -> ``%3B``
172 - ``\\`` -> ``%5C``
173
174 A literal ``%`` is escaped first so that percent sequences already in
175 the value (e.g. ``%2C`` in a URI) are not confused with the markers
176 introduced here. ``_unescape_string`` reverses it.
177 """
178 # f'{i:02X}'
179 return (
180 val.replace("%", "%25")
181 .replace(r"\,", "%2C")
182 .replace(r"\:", "%3A")
183 .replace(r"\;", "%3B")
184 .replace(r"\\", "%5C")
185 )
186
187
188def _unescape_string(val: str) -> str:
189 r"""Unescape URL-encoded hex values to their original characters.
190
191 Reverses ``_escape_string`` by converting percent-encoded hex values
192 back to their original characters. This is used for parameter parsing.
193
194 Parameters:
195 val: The string with percent-encoded values.
196
197 Returns:
198 The string with percent encoding converted to characters.
199
200 Note:
201 Conversions:
202
203 - ``%2C`` -> ``,``
204 - ``%3A`` -> ``:``
205 - ``%3B`` -> ``;``
206 - ``%5C`` -> ``\``
207 - ``%25`` -> ``%``
208
209 ``%25`` is restored last so a literal ``%`` that ``_escape_string``
210 protected does not re-trigger the marker replacements above.
211 """
212 return (
213 val.replace("%2C", ",")
214 .replace("%3A", ":")
215 .replace("%3B", ";")
216 .replace("%5C", "\\")
217 .replace("%25", "%")
218 )
219
220
221# [\w-] because of the iCalendar RFC
222# . because of the vCard RFC
223NAME = re.compile(r"[\w.-]+", re.ASCII)
224
225
226def validate_token(name: str) -> None:
227 r"""Validate that a name is a valid iCalendar token.
228
229 Checks if the name matches the :rfc:`5545` token syntax using the NAME
230 regex pattern (``[\w.-]+``), with ``\w`` matching ASCII characters only.
231
232 Parameters:
233 name: Required. The token name to validate.
234
235 Raises:
236 ~icalendar.error.InvalidToken: If the name is not a valid token.
237
238 .. versionchanged:: 0.0.0
239 Raise :exc:`~icalendar.error.InvalidToken`, a subclass of
240 :exc:`ValueError`, and reject non-ASCII characters.
241 """
242 match = NAME.findall(name)
243 if len(match) == 1 and name == match[0]:
244 return
245 raise InvalidToken(name)
246
247
248__all__ = [
249 "_escape_char",
250 "_escape_string",
251 "_foldline",
252 "_unescape_char",
253 "_unescape_string",
254 "validate_token",
255]