1use std::borrow::Cow;
10use std::fmt;
11use std::num::ParseIntError;
12use std::ops::Range;
13use std::slice::Iter;
14
15use oxixml_support::bytes::{memchr, memchr3};
16
17#[cfg(feature = "escape-html")]
18mod html5;
19#[cfg(feature = "escape-html")]
20#[cfg_attr(docsrs, doc(cfg(feature = "escape-html")))]
21pub use html5::resolve_html5_entity;
22
23#[derive(Clone, Debug, PartialEq)]
25pub enum ParseCharRefError {
26 UnexpectedSign,
28 InvalidNumber(ParseIntError),
30 InvalidCodepoint(u32),
32 IllegalCharacter(u32),
36}
37
38impl fmt::Display for ParseCharRefError {
39 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
40 match self {
41 Self::UnexpectedSign => f.write_str("unexpected number sign"),
42 Self::InvalidNumber(e) => e.fmt(f),
43 Self::InvalidCodepoint(n) => write!(f, "`{}` is not a valid codepoint", n),
44 Self::IllegalCharacter(n) => write!(f, "0x{:x} character is not permitted in XML", n),
45 }
46 }
47}
48
49impl std::error::Error for ParseCharRefError {
50 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51 match self {
52 Self::InvalidNumber(e) => Some(e),
53 _ => None,
54 }
55 }
56}
57
58#[derive(Clone, Debug, PartialEq)]
60pub enum EscapeError {
61 UnrecognizedEntity(Range<usize>, String),
64 UnterminatedEntity(Range<usize>),
66 InvalidCharRef(ParseCharRefError),
69 TooManyNestedEntities,
72}
73
74impl fmt::Display for EscapeError {
75 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76 match self {
77 Self::UnrecognizedEntity(rge, res) => {
78 write!(f, "at {:?}: unrecognized entity `{}`", rge, res)
79 }
80 Self::UnterminatedEntity(e) => write!(
81 f,
82 "Error while escaping character at range {:?}: Cannot find ';' after '&'",
83 e
84 ),
85 Self::InvalidCharRef(e) => write!(f, "invalid character reference: {}", e),
86 Self::TooManyNestedEntities => {
87 f.write_str("too many nested entities in an attribute value")
88 }
89 }
90 }
91}
92
93impl std::error::Error for EscapeError {
94 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95 match self {
96 Self::InvalidCharRef(e) => Some(e),
97 _ => None,
98 }
99 }
100}
101
102pub fn escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
113 _escape(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"'))
114}
115
116pub fn partial_escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
121 _escape(raw, |ch| matches!(ch, b'<' | b'>' | b'&'))
122}
123
124pub fn minimal_escape<'a>(raw: impl Into<Cow<'a, str>>) -> Cow<'a, str> {
129 _escape(raw, |ch| matches!(ch, b'<' | b'&'))
130}
131
132pub(crate) fn escape_char<W>(writer: &mut W, value: &str, from: usize, to: usize) -> fmt::Result
134where
135 W: fmt::Write,
136{
137 writer.write_str(&value[from..to])?;
138 match value.as_bytes()[to] {
139 b'<' => writer.write_str("<")?,
140 b'>' => writer.write_str(">")?,
141 b'\'' => writer.write_str("'")?,
142 b'&' => writer.write_str("&")?,
143 b'"' => writer.write_str(""")?,
144
145 b'\t' => writer.write_str("	")?,
148 b'\n' => writer.write_str(" ")?,
149 b'\r' => writer.write_str(" ")?,
150 b' ' => writer.write_str(" ")?,
151 _ => {
152 unreachable!("only '<', '>', '\\'', '&', '\"', '\\t', '\\r', '\\n' and ' ' are escaped")
153 }
154 }
155 Ok(())
156}
157
158fn _escape<'a, F: Fn(u8) -> bool>(raw: impl Into<Cow<'a, str>>, escape_chars: F) -> Cow<'a, str> {
161 let raw = raw.into();
162 let bytes = raw.as_bytes();
163 let mut escaped: Option<String> = None;
164 let mut iter = bytes.iter();
165 let mut pos = 0;
166 while let Some(i) = iter.position(|&b| escape_chars(b)) {
167 let escaped = escaped.get_or_insert_with(|| String::with_capacity(raw.len()));
168 let new_pos = pos + i;
169 let _ = escape_char(escaped, &raw, pos, new_pos);
171 pos = new_pos + 1;
172 }
173
174 if let Some(mut escaped) = escaped {
175 if let Some(rest) = raw.get(pos..) {
176 escaped.push_str(rest);
177 }
178 Cow::Owned(escaped)
179 } else {
180 raw
181 }
182}
183
184pub fn unescape(raw: &str) -> Result<Cow<'_, str>, EscapeError> {
191 unescape_with(raw, resolve_predefined_entity)
192}
193
194pub fn unescape_with<'input, 'entity, F>(
220 raw: &'input str,
221 mut resolve_entity: F,
222) -> Result<Cow<'input, str>, EscapeError>
223where
224 F: FnMut(&str) -> Option<&'entity str>,
226{
227 let bytes = raw.as_bytes();
228 let mut unescaped: Option<String> = None;
229 let mut last_end = 0;
230 let mut iter = bytes
232 .iter()
233 .enumerate()
234 .filter_map(|(i, &b)| (b == b'&' || b == b';').then_some(i));
235 while let Some(start) = iter.by_ref().find(|p| bytes[*p] == b'&') {
236 match iter.next() {
237 Some(end) if bytes[end] == b';' => {
238 let unescaped = unescaped.get_or_insert_with(|| String::with_capacity(raw.len()));
239 unescaped.push_str(&raw[last_end..start]);
240
241 let pat = &raw[start + 1..end];
242 if let Some(entity) = pat.strip_prefix('#') {
243 let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
244 unescaped.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
245 } else if let Some(value) = resolve_entity(pat) {
246 unescaped.push_str(value);
247 } else {
248 return Err(EscapeError::UnrecognizedEntity(
249 start + 1..end,
250 pat.to_string(),
251 ));
252 }
253
254 last_end = end + 1;
255 }
256 _ => return Err(EscapeError::UnterminatedEntity(start..raw.len())),
257 }
258 }
259
260 if let Some(mut unescaped) = unescaped {
261 if let Some(rest) = raw.get(last_end..) {
262 unescaped.push_str(rest);
263 }
264 Ok(Cow::Owned(unescaped))
265 } else {
266 Ok(Cow::Borrowed(raw))
267 }
268}
269
270pub(crate) fn normalize_xml11_eols(text: &str) -> Cow<'_, str> {
274 let bytes = text.as_bytes();
275
276 if let Some(i) = memchr3(b'\r', 0xC2, 0xE2, bytes) {
280 let mut normalized = String::with_capacity(text.len());
281 normalized.push_str(&text[0..i]);
282
283 let mut pos = normalize_xml11_eol_step(&mut normalized, text, i, '\n');
284 while let Some(i) = memchr3(b'\r', 0xC2, 0xE2, &bytes[pos..]) {
285 let index = pos + i;
286 normalized.push_str(&text[pos..index]);
287 pos = normalize_xml11_eol_step(&mut normalized, text, index, '\n');
288 }
289 if let Some(rest) = text.get(pos..) {
290 normalized.push_str(rest);
291 }
292 return normalized.into();
293 }
294 Cow::Borrowed(text)
295}
296
297fn normalize_xml11_eol_step(normalized: &mut String, text: &str, index: usize, ch: char) -> usize {
301 let input = text.as_bytes();
302 match input[index] {
303 b'\r' => {
304 if index + 1 < input.len() {
305 let next = input[index + 1];
306 if next == b'\n' {
307 normalized.push(ch);
308 return index + 2; }
310 if next == 0xC2 {
311 if index + 2 < input.len() && input[index + 2] == 0x85 {
313 normalized.push(ch);
314 } else {
315 normalized.push(ch);
316 normalized.push_str(&text[index + 1..index + 3]);
317 }
318 return index + 3; }
320 }
321 normalized.push(ch);
322 index + 1 }
324 b'\n' => {
325 normalized.push(ch);
326 index + 1 }
328 0xC2 => {
330 if index + 1 < input.len() && input[index + 1] == 0x85 {
331 normalized.push(ch);
332 } else {
333 normalized.push_str(&text[index..index + 2]);
334 }
335 index + 2 }
337 0xE2 => {
339 if index + 2 < input.len() && input[index + 1] == 0x80 && input[index + 2] == 0xA8 {
340 normalized.push(ch);
341 } else {
342 normalized.push_str(&text[index..index + 3]);
343 }
344 index + 3 }
346 _ => index + 1,
347 }
348}
349
350pub(crate) fn normalize_xml10_eols(text: &str) -> Cow<'_, str> {
354 let bytes = text.as_bytes();
355
356 if let Some(i) = memchr(b'\r', bytes) {
359 let mut normalized = String::with_capacity(text.len());
360 normalized.push_str(&text[0..i]);
361
362 let mut pos = normalize_xml10_eol_step(&mut normalized, text, i, '\n');
363 while let Some(i) = memchr(b'\r', &bytes[pos..]) {
364 let index = pos + i;
365 normalized.push_str(&text[pos..index]);
366 pos = normalize_xml10_eol_step(&mut normalized, text, index, '\n');
367 }
368 if let Some(rest) = text.get(pos..) {
369 normalized.push_str(rest);
370 }
371 return normalized.into();
372 }
373 Cow::Borrowed(text)
374}
375
376fn normalize_xml10_eol_step(normalized: &mut String, text: &str, index: usize, ch: char) -> usize {
378 let input = text.as_bytes();
379 match input[index] {
380 b'\r' => {
381 normalized.push(ch);
382 if index + 1 < input.len() && input[index + 1] == b'\n' {
383 return index + 2; }
385 index + 1 }
387 b'\n' => {
388 normalized.push(ch);
389 index + 1 }
391 _ => index + 1,
392 }
393}
394
395pub(crate) fn normalize_xml10_attribute_value<'input, 'entity, F>(
398 value: &'input str,
399 depth: usize,
400 resolve_entity: F,
401) -> Result<Cow<'input, str>, EscapeError>
402where
403 F: FnMut(&str) -> Option<&'entity str>,
404{
405 normalize_attribute_value(
406 value,
407 depth,
408 is_xml10_normalization_char,
409 normalize_xml10_eol_step,
410 resolve_entity,
411 )
412}
413
414const fn is_xml10_normalization_char(b: &u8) -> bool {
415 matches!(*b, b'\t' | b'\r' | b'\n' | b'&')
416}
417
418pub(crate) fn normalize_xml11_attribute_value<'input, 'entity, F>(
419 value: &'input str,
420 depth: usize,
421 resolve_entity: F,
422) -> Result<Cow<'input, str>, EscapeError>
423where
424 F: FnMut(&str) -> Option<&'entity str>,
425{
426 normalize_attribute_value(
427 value,
428 depth,
429 is_xml11_normalization_char,
430 normalize_xml11_eol_step,
431 resolve_entity,
432 )
433}
434
435const fn is_xml11_normalization_char(b: &u8) -> bool {
436 matches!(*b, b'\t' | b'\r' | b'\n' | 0xC2 | 0xE2 | b'&')
437}
438
439pub fn normalize_attribute_value<'input, 'entity, C, E, F>(
462 value: &'input str,
463 depth: usize,
464 is_normalization_char: C,
465 normalize_eol_step: E,
466 mut resolve_entity: F,
467) -> Result<Cow<'input, str>, EscapeError>
468where
469 C: Fn(&u8) -> bool,
470 E: Fn(&mut String, &str, usize, char) -> usize,
471 F: FnMut(&str) -> Option<&'entity str>,
472{
473 let mut iter = value.as_bytes().iter();
474
475 if let Some(i) = iter.position(&is_normalization_char) {
476 let mut normalized = String::with_capacity(value.len());
477 let pos = normalize_attr_step(
478 &mut normalized,
479 &mut iter,
480 value,
481 0,
482 i,
483 depth,
484 &is_normalization_char,
485 &normalize_eol_step,
486 &mut resolve_entity,
487 )?;
488
489 normalize_attr_steps(
490 &mut normalized,
491 &mut iter,
492 value,
493 pos,
494 depth,
495 &is_normalization_char,
496 &normalize_eol_step,
497 &mut resolve_entity,
498 )?;
499 return Ok(normalized.into());
500 }
501 Ok(Cow::Borrowed(value))
502}
503
504#[allow(clippy::too_many_arguments)]
505fn normalize_attr_steps<'entity, C, E, F>(
506 normalized: &mut String,
507 iter: &mut Iter<u8>,
508 input: &str,
509 mut pos: usize,
510 depth: usize,
511 is_normalization_char: &C,
512 normalize_eol_step: &E,
513 resolve_entity: &mut F,
514) -> Result<(), EscapeError>
515where
516 C: Fn(&u8) -> bool,
517 E: Fn(&mut String, &str, usize, char) -> usize,
518 F: FnMut(&str) -> Option<&'entity str>,
519{
520 while let Some(i) = iter.position(is_normalization_char) {
521 pos = normalize_attr_step(
522 normalized,
523 iter,
524 input,
525 pos,
526 pos + i,
527 depth,
528 is_normalization_char,
529 normalize_eol_step,
530 resolve_entity,
531 )?;
532 }
533 if let Some(rest) = input.get(pos..) {
534 normalized.push_str(rest);
535 }
536 Ok(())
537}
538
539#[allow(clippy::too_many_arguments)]
544fn normalize_attr_step<'entity, C, E, F>(
545 normalized: &mut String,
546 iter: &mut Iter<u8>,
547 input: &str,
548 last_pos: usize,
549 index: usize,
550 depth: usize,
551 is_normalization_char: &C,
552 normalize_eol_step: &E,
553 resolve_entity: &mut F,
554) -> Result<usize, EscapeError>
555where
556 C: Fn(&u8) -> bool,
557 E: Fn(&mut String, &str, usize, char) -> usize,
558 F: FnMut(&str) -> Option<&'entity str>,
559{
560 if depth == 0 {
561 return Err(EscapeError::TooManyNestedEntities);
562 }
563 normalized.push_str(&input[last_pos..index]);
565
566 match input.as_bytes()[index] {
567 b'&' => {
568 let start = index + 1; let end = start
570 + match iter.position(|&b| b == b';') {
571 Some(end) => end,
572 None => return Err(EscapeError::UnterminatedEntity(index..input.len())),
573 };
574
575 let pat = &input[start..end];
576 if let Some(entity) = pat.strip_prefix('#') {
577 let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
579 normalized.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
580 } else if pat == "amp" {
581 normalized.push('&');
584 } else if let Some(value) = resolve_entity(pat) {
585 normalize_attr_steps(
587 normalized,
588 &mut value.as_bytes().iter(),
589 value,
590 0,
591 depth.saturating_sub(1),
592 is_normalization_char,
593 normalize_eol_step,
594 resolve_entity,
595 )?;
596 } else {
597 return Err(EscapeError::UnrecognizedEntity(start..end, pat.to_string()));
598 }
599 Ok(end + 1) }
601 b'\t' => {
603 normalized.push(' ');
604 Ok(index + 1) }
606 _ => {
607 let pos = normalize_eol_step(normalized, input, index, ' ');
608 for _ in 0..pos - index - 1 {
610 iter.next();
611 }
612 Ok(pos)
613 }
614 }
615}
616
617#[cfg_attr(feature = "escape-html", doc = "[`resolve_html5_entity`] with it.")]
624#[cfg_attr(not(feature = "escape-html"), doc = "`resolve_html5_entity` with it.")]
625#[inline]
626pub const fn resolve_predefined_entity(entity: &str) -> Option<&'static str> {
627 #[cfg(not(feature = "escape-html"))]
628 {
629 resolve_xml_entity(entity)
630 }
631
632 #[cfg(feature = "escape-html")]
633 {
634 resolve_html5_entity(entity)
635 }
636}
637
638pub const fn resolve_xml_entity(entity: &str) -> Option<&'static str> {
651 let s = match entity.as_bytes() {
653 b"lt" => "<",
654 b"gt" => ">",
655 b"amp" => "&",
656 b"apos" => "'",
657 b"quot" => "\"",
658 _ => return None,
659 };
660 Some(s)
661}
662
663pub(crate) fn parse_number(num: &str) -> Result<char, ParseCharRefError> {
666 let code = if let Some(hex) = num.strip_prefix('x') {
667 from_str_radix(hex, 16)?
668 } else {
669 from_str_radix(num, 10)?
670 };
671 if code == 0 {
672 return Err(ParseCharRefError::IllegalCharacter(code));
673 }
674 match std::char::from_u32(code) {
675 Some(c) => Ok(c),
676 None => Err(ParseCharRefError::InvalidCodepoint(code)),
677 }
678}
679
680#[inline]
681fn from_str_radix(src: &str, radix: u32) -> Result<u32, ParseCharRefError> {
682 match src.as_bytes().first().copied() {
683 Some(b'+') | Some(b'-') => Err(ParseCharRefError::UnexpectedSign),
686 _ => u32::from_str_radix(src, radix).map_err(ParseCharRefError::InvalidNumber),
687 }
688}
689
690#[cfg(test)]
691mod tests;