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
414pub(crate) const fn is_xml10_normalization_char(b: &u8) -> bool {
419 matches!(*b, b'\t' | b'\r' | b'\n' | b'&')
420}
421
422pub(crate) fn normalize_xml11_attribute_value<'input, 'entity, F>(
423 value: &'input str,
424 depth: usize,
425 resolve_entity: F,
426) -> Result<Cow<'input, str>, EscapeError>
427where
428 F: FnMut(&str) -> Option<&'entity str>,
429{
430 normalize_attribute_value(
431 value,
432 depth,
433 is_xml11_normalization_char,
434 normalize_xml11_eol_step,
435 resolve_entity,
436 )
437}
438
439const fn is_xml11_normalization_char(b: &u8) -> bool {
440 matches!(*b, b'\t' | b'\r' | b'\n' | 0xC2 | 0xE2 | b'&')
441}
442
443pub fn normalize_attribute_value<'input, 'entity, C, E, F>(
466 value: &'input str,
467 depth: usize,
468 is_normalization_char: C,
469 normalize_eol_step: E,
470 mut resolve_entity: F,
471) -> Result<Cow<'input, str>, EscapeError>
472where
473 C: Fn(&u8) -> bool,
474 E: Fn(&mut String, &str, usize, char) -> usize,
475 F: FnMut(&str) -> Option<&'entity str>,
476{
477 let mut iter = value.as_bytes().iter();
478
479 if let Some(i) = iter.position(&is_normalization_char) {
480 let mut normalized = String::with_capacity(value.len());
481 let pos = normalize_attr_step(
482 &mut normalized,
483 &mut iter,
484 value,
485 0,
486 i,
487 depth,
488 false,
489 &is_normalization_char,
490 &normalize_eol_step,
491 &mut resolve_entity,
492 )?;
493
494 normalize_attr_steps(
495 &mut normalized,
496 &mut iter,
497 value,
498 pos,
499 depth,
500 false,
501 &is_normalization_char,
502 &normalize_eol_step,
503 &mut resolve_entity,
504 )?;
505 return Ok(normalized.into());
506 }
507 Ok(Cow::Borrowed(value))
508}
509
510#[allow(clippy::too_many_arguments)]
518fn normalize_attr_steps<'entity, C, E, F>(
519 normalized: &mut String,
520 iter: &mut Iter<u8>,
521 input: &str,
522 mut pos: usize,
523 depth: usize,
524 in_entity: bool,
525 is_normalization_char: &C,
526 normalize_eol_step: &E,
527 resolve_entity: &mut F,
528) -> Result<(), EscapeError>
529where
530 C: Fn(&u8) -> bool,
531 E: Fn(&mut String, &str, usize, char) -> usize,
532 F: FnMut(&str) -> Option<&'entity str>,
533{
534 while let Some(i) = iter.position(is_normalization_char) {
535 pos = normalize_attr_step(
536 normalized,
537 iter,
538 input,
539 pos,
540 pos + i,
541 depth,
542 in_entity,
543 is_normalization_char,
544 normalize_eol_step,
545 resolve_entity,
546 )?;
547 }
548 if let Some(rest) = input.get(pos..) {
549 normalized.push_str(rest);
550 }
551 Ok(())
552}
553
554#[allow(clippy::too_many_arguments)]
559fn normalize_attr_step<'entity, C, E, F>(
560 normalized: &mut String,
561 iter: &mut Iter<u8>,
562 input: &str,
563 last_pos: usize,
564 index: usize,
565 depth: usize,
566 in_entity: bool,
567 is_normalization_char: &C,
568 normalize_eol_step: &E,
569 resolve_entity: &mut F,
570) -> Result<usize, EscapeError>
571where
572 C: Fn(&u8) -> bool,
573 E: Fn(&mut String, &str, usize, char) -> usize,
574 F: FnMut(&str) -> Option<&'entity str>,
575{
576 if depth == 0 {
577 return Err(EscapeError::TooManyNestedEntities);
578 }
579 normalized.push_str(&input[last_pos..index]);
581
582 match input.as_bytes()[index] {
583 b'&' => {
584 let start = index + 1; let end = start
586 + match iter.position(|&b| b == b';') {
587 Some(end) => end,
588 None => return Err(EscapeError::UnterminatedEntity(index..input.len())),
589 };
590
591 let pat = &input[start..end];
592 if let Some(entity) = pat.strip_prefix('#') {
593 let codepoint = parse_number(entity).map_err(EscapeError::InvalidCharRef)?;
595 normalized.push_str(codepoint.encode_utf8(&mut [0u8; 4]));
596 } else if pat == "amp" {
597 normalized.push('&');
600 } else if let Some(value) = resolve_entity(pat) {
601 normalize_attr_steps(
603 normalized,
604 &mut value.as_bytes().iter(),
605 value,
606 0,
607 depth.saturating_sub(1),
608 true,
609 is_normalization_char,
610 normalize_eol_step,
611 resolve_entity,
612 )?;
613 } else {
614 return Err(EscapeError::UnrecognizedEntity(start..end, pat.to_string()));
615 }
616 Ok(end + 1) }
618 b'\t' => {
620 normalized.push(' ');
621 Ok(index + 1) }
623 _ => {
624 let pos = if in_entity {
625 normalize_entity_whitespace_step(normalized, input, index)
626 } else {
627 normalize_eol_step(normalized, input, index, ' ')
628 };
629 for _ in 0..pos - index - 1 {
631 iter.next();
632 }
633 Ok(pos)
634 }
635 }
636}
637
638fn normalize_entity_whitespace_step(normalized: &mut String, text: &str, index: usize) -> usize {
652 let input = text.as_bytes();
653 match input[index] {
654 b'\r' | b'\n' => {
655 normalized.push(' ');
656 index + 1
657 }
658 0xC2 => {
660 if index + 1 < input.len() && input[index + 1] == 0x85 {
661 normalized.push(' ');
662 } else {
663 normalized.push_str(&text[index..index + 2]);
664 }
665 index + 2
666 }
667 0xE2 => {
669 if index + 2 < input.len() && input[index + 1] == 0x80 && input[index + 2] == 0xA8 {
670 normalized.push(' ');
671 } else {
672 normalized.push_str(&text[index..index + 3]);
673 }
674 index + 3
675 }
676 _ => index + 1,
677 }
678}
679
680#[cfg_attr(feature = "escape-html", doc = "[`resolve_html5_entity`] with it.")]
687#[cfg_attr(not(feature = "escape-html"), doc = "`resolve_html5_entity` with it.")]
688#[inline]
689pub const fn resolve_predefined_entity(entity: &str) -> Option<&'static str> {
690 #[cfg(not(feature = "escape-html"))]
691 {
692 resolve_xml_entity(entity)
693 }
694
695 #[cfg(feature = "escape-html")]
696 {
697 resolve_html5_entity(entity)
698 }
699}
700
701pub const fn resolve_xml_entity(entity: &str) -> Option<&'static str> {
714 let s = match entity.as_bytes() {
716 b"lt" => "<",
717 b"gt" => ">",
718 b"amp" => "&",
719 b"apos" => "'",
720 b"quot" => "\"",
721 _ => return None,
722 };
723 Some(s)
724}
725
726pub(crate) fn parse_number(num: &str) -> Result<char, ParseCharRefError> {
729 let code = if let Some(hex) = num.strip_prefix('x') {
730 from_str_radix(hex, 16)?
731 } else {
732 from_str_radix(num, 10)?
733 };
734 if code == 0 {
735 return Err(ParseCharRefError::IllegalCharacter(code));
736 }
737 match std::char::from_u32(code) {
738 Some(c) => Ok(c),
739 None => Err(ParseCharRefError::InvalidCodepoint(code)),
740 }
741}
742
743#[inline]
744fn from_str_radix(src: &str, radix: u32) -> Result<u32, ParseCharRefError> {
745 match src.as_bytes().first().copied() {
746 Some(b'+') | Some(b'-') => Err(ParseCharRefError::UnexpectedSign),
749 _ => u32::from_str_radix(src, radix).map_err(ParseCharRefError::InvalidNumber),
750 }
751}
752
753#[cfg(test)]
754mod tests;