pub fn escape_xml(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => result.push_str("&"),
'<' => result.push_str("<"),
'>' => result.push_str(">"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
c => result.push(c),
}
}
result
}
pub fn extract_xml_tag_value(xml: &str, tag_name: &str) -> Option<String> {
let open = format!("<{tag_name}>");
let close = format!("</{tag_name}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
pub fn tag(name: &str, attrs: &[(&str, &str)], children: TagContent<'_>) -> String {
use std::fmt::Write as _;
let attr_str: String = attrs.iter().fold(String::new(), |mut s, (k, v)| {
let _ = write!(s, " {k}=\"{}\"", escape_xml(v));
s
});
match children {
TagContent::None => format!("<{name}{attr_str}></{name}>"),
TagContent::Text(text) => {
format!("<{name}{attr_str}>{}</{name}>", escape_xml(text))
}
TagContent::Children(kids) => {
let inner: String = kids.into_iter().collect();
format!("<{name}{attr_str}>{inner}</{name}>")
}
}
}
#[non_exhaustive]
pub enum TagContent<'a> {
None,
Text(&'a str),
Children(Vec<String>),
}
impl<'a> From<&'a str> for TagContent<'a> {
fn from(s: &'a str) -> Self {
TagContent::Text(s)
}
}
impl From<Vec<String>> for TagContent<'_> {
fn from(v: Vec<String>) -> Self {
TagContent::Children(v)
}
}
impl From<String> for TagContent<'_> {
fn from(s: String) -> Self {
TagContent::Text(Box::leak(s.into_boxed_str()))
}
}
pub fn pretty_print_xml(xml: &str) -> String {
let mut tokens: Vec<XmlToken> = Vec::new();
let mut pos = 0;
let bytes = xml.as_bytes();
while pos < bytes.len() {
if bytes[pos] == b'<' {
let end = xml[pos..]
.find('>')
.map(|i| pos + i + 1)
.unwrap_or(bytes.len());
tokens.push(XmlToken::Tag(xml[pos..end].to_string()));
pos = end;
} else {
let end = xml[pos..].find('<').map(|i| pos + i).unwrap_or(bytes.len());
let text = &xml[pos..end];
if !text.trim().is_empty() {
tokens.push(XmlToken::Text(text.trim().to_string()));
}
pos = end;
}
}
let indent = " ";
let mut result = String::with_capacity(xml.len() * 2);
let mut depth: usize = 0;
let mut i = 0;
while i < tokens.len() {
match &tokens[i] {
XmlToken::Tag(t) if t.starts_with("<?") => {
result.push_str(t);
result.push('\n');
}
XmlToken::Tag(t) if t.starts_with("</") => {
depth = depth.saturating_sub(1);
for _ in 0..depth {
result.push_str(indent);
}
result.push_str(t);
result.push('\n');
}
XmlToken::Tag(t) if t.ends_with("/>") => {
for _ in 0..depth {
result.push_str(indent);
}
result.push_str(t);
result.push('\n');
}
XmlToken::Tag(t) => {
if i + 2 < tokens.len() {
if let (XmlToken::Text(text), XmlToken::Tag(close)) =
(&tokens[i + 1], &tokens[i + 2])
{
if close.starts_with("</") {
for _ in 0..depth {
result.push_str(indent);
}
result.push_str(t);
result.push_str(text);
result.push_str(close);
result.push('\n');
i += 3;
continue;
}
}
}
for _ in 0..depth {
result.push_str(indent);
}
result.push_str(t);
result.push('\n');
depth += 1;
}
XmlToken::Text(t) => {
for _ in 0..depth {
result.push_str(indent);
}
result.push_str(t);
result.push('\n');
}
}
i += 1;
}
while result.ends_with('\n') {
result.pop();
}
result
}
enum XmlToken {
Tag(String),
Text(String),
}
pub fn replace_unacceptable_characters(input: &str) -> String {
if input.is_empty() {
return String::new();
}
let s = input.replace(['<', '>'], "");
let s = s.replace('&', " & ");
let s = s.replace(['\'', '"'], "");
let s = collapse_whitespace(&s);
let s = s.replace('&', "&");
let s = s.replace(['\r', '\t', '\n'], "");
let s = collapse_whitespace(&s);
let s: String = s
.chars()
.filter(|&c| !c.is_ascii_control() || c == ' ')
.collect();
s.trim().to_string()
}
fn collapse_whitespace(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_ws = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !prev_ws {
result.push(' ');
}
prev_ws = true;
} else {
result.push(ch);
prev_ws = false;
}
}
result
}
pub fn validate_xml(xml: &str) -> Result<(), crate::FiscalError> {
let mut errors: Vec<String> = Vec::new();
let required_structure = [
("NFe", "Elemento raiz <NFe> ausente"),
("infNFe", "Elemento <infNFe> ausente"),
];
for (tag_name, msg) in &required_structure {
if !xml.contains(&format!("<{tag_name}")) {
errors.push(msg.to_string());
}
}
let ide_tags = [
"cUF", "cNF", "natOp", "mod", "serie", "nNF", "dhEmi", "tpNF", "idDest", "cMunFG", "tpImp",
"tpEmis", "cDV", "tpAmb", "finNFe", "indFinal", "indPres", "procEmi", "verProc",
];
for tag_name in &ide_tags {
if extract_xml_tag_value(xml, tag_name).is_none() {
errors.push(format!("Tag obrigatória <{tag_name}> ausente em <ide>"));
}
}
let emit_required = ["xNome", "IE", "CRT"];
for tag_name in &emit_required {
if extract_xml_tag_value(xml, tag_name).is_none() {
errors.push(format!("Tag obrigatória <{tag_name}> ausente em <emit>"));
}
}
if extract_xml_tag_value(xml, "CNPJ").is_none() && extract_xml_tag_value(xml, "CPF").is_none() {
errors.push("Tag <CNPJ> ou <CPF> ausente em <emit>".to_string());
}
let required_blocks = [
("enderEmit", "Bloco <enderEmit> ausente"),
("det ", "Nenhum item <det> encontrado"),
("total", "Bloco <total> ausente"),
("ICMSTot", "Bloco <ICMSTot> ausente"),
("transp", "Bloco <transp> ausente"),
("pag", "Bloco <pag> ausente"),
];
for (fragment, msg) in &required_blocks {
if !xml.contains(&format!("<{fragment}")) {
errors.push(msg.to_string());
}
}
if let Some(id_start) = xml.find("Id=\"NFe") {
let after_id = &xml[id_start + 7..];
if let Some(quote_end) = after_id.find('"') {
let key = &after_id[..quote_end];
if key.len() != 44 || !key.chars().all(|c| c.is_ascii_digit()) {
errors.push(format!(
"Chave de acesso inválida: esperado 44 dígitos, encontrado '{key}'"
));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(crate::FiscalError::XmlParsing(errors.join("; ")))
}
}
pub fn remove_invalid_xml_chars(input: &str) -> String {
let mut result = String::with_capacity(input.len());
for ch in input.chars() {
if is_valid_xml_char(ch) {
result.push(ch);
}
}
result
}
fn is_valid_xml_char(ch: char) -> bool {
matches!(ch,
'\u{09}' | '\u{0A}' | '\u{0D}' |
'\u{20}'..='\u{D7FF}' |
'\u{E000}'..='\u{FFFD}' |
'\u{10000}'..='\u{10FFFF}'
)
}
pub fn clear_xml_string(input: &str, remove_encoding_tag: bool) -> String {
let mut result = input.to_string();
let removals = [
"xmlns:default=\"http://www.w3.org/2000/09/xmldsig#\"",
" standalone=\"no\"",
"default:",
":default",
"\n",
"\r",
"\t",
];
for pattern in &removals {
result = result.replace(pattern, "");
}
let mut collapsed = String::with_capacity(result.len());
let mut chars = result.chars().peekable();
while let Some(ch) = chars.next() {
collapsed.push(ch);
if ch == '>' {
let mut ws_buf = String::new();
while let Some(&next) = chars.peek() {
if next.is_ascii_whitespace() {
ws_buf.push(next);
chars.next();
} else {
break;
}
}
if let Some(&next) = chars.peek() {
if next != '<' {
collapsed.push_str(&ws_buf);
}
} else {
collapsed.push_str(&ws_buf);
}
}
}
result = collapsed;
if remove_encoding_tag {
result = delete_all_between(&result, "<?xml", "?>");
}
result
}
fn delete_all_between(input: &str, beginning: &str, end: &str) -> String {
let begin_pos = match input.find(beginning) {
Some(p) => p,
None => return input.to_string(),
};
let after_begin = begin_pos + beginning.len();
let end_pos = match input[after_begin..].find(end) {
Some(p) => after_begin + p + end.len(),
None => return input.to_string(),
};
let mut result = String::with_capacity(input.len() - (end_pos - begin_pos));
result.push_str(&input[..begin_pos]);
result.push_str(&input[end_pos..]);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pretty_print_simple_xml() {
let compact = "<root><child>text</child></root>";
let pretty = pretty_print_xml(compact);
assert!(pretty.contains("<root>"));
assert!(pretty.contains(" <child>text</child>"));
assert!(pretty.contains("</root>"));
}
#[test]
fn pretty_print_nested_xml() {
let compact = "<a><b><c>val</c></b></a>";
let pretty = pretty_print_xml(compact);
let lines: Vec<&str> = pretty.lines().collect();
assert_eq!(lines[0], "<a>");
assert_eq!(lines[1], " <b>");
assert_eq!(lines[2], " <c>val</c>");
assert_eq!(lines[3], " </b>");
assert_eq!(lines[4], "</a>");
}
#[test]
fn pretty_print_with_declaration() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><a>1</a></root>";
let pretty = pretty_print_xml(xml);
assert!(pretty.starts_with("<?xml"));
assert!(pretty.contains(" <a>1</a>"));
}
#[test]
fn pretty_print_empty_input() {
let pretty = pretty_print_xml("");
assert_eq!(pretty, "");
}
#[test]
fn validate_xml_valid_nfe() {
let xml = concat!(
r#"<NFe><infNFe versao="4.00" Id="NFe41260304123456000190550010000001231123456780">"#,
"<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
"<mod>55</mod><serie>1</serie><nNF>123</nNF>",
"<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
"<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
"<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
"<tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal>",
"<indPres>1</indPres><procEmi>0</procEmi><verProc>1.0</verProc></ide>",
"<emit><CNPJ>04123456000190</CNPJ><xNome>Test</xNome>",
"<enderEmit><xLgr>Rua</xLgr></enderEmit>",
"<IE>9012345678</IE><CRT>3</CRT></emit>",
"<det nItem=\"1\"><prod><cProd>001</cProd></prod></det>",
"<total><ICMSTot><vNF>150.00</vNF></ICMSTot></total>",
"<transp><modFrete>9</modFrete></transp>",
"<pag><detPag><tPag>01</tPag><vPag>150.00</vPag></detPag></pag>",
"</infNFe></NFe>",
);
assert!(validate_xml(xml).is_ok());
}
#[test]
fn validate_xml_missing_tags() {
let xml = "<root><something>val</something></root>";
let err = validate_xml(xml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("NFe"));
assert!(msg.contains("infNFe"));
}
#[test]
fn validate_xml_invalid_access_key() {
let xml = concat!(
r#"<NFe><infNFe versao="4.00" Id="NFe123">"#,
"<ide><cUF>41</cUF><cNF>12345678</cNF><natOp>VENDA</natOp>",
"<mod>55</mod><serie>1</serie><nNF>123</nNF>",
"<dhEmi>2026-03-11T10:30:00-03:00</dhEmi>",
"<tpNF>1</tpNF><idDest>1</idDest><cMunFG>4106902</cMunFG>",
"<tpImp>1</tpImp><tpEmis>1</tpEmis><cDV>0</cDV>",
"<tpAmb>2</tpAmb><finNFe>1</finNFe><indFinal>1</indFinal>",
"<indPres>1</indPres><procEmi>0</procEmi><verProc>1.0</verProc></ide>",
"<emit><CNPJ>04123456000190</CNPJ><xNome>Test</xNome>",
"<enderEmit><xLgr>Rua</xLgr></enderEmit>",
"<IE>9012345678</IE><CRT>3</CRT></emit>",
"<det nItem=\"1\"><prod><cProd>001</cProd></prod></det>",
"<total><ICMSTot><vNF>150.00</vNF></ICMSTot></total>",
"<transp><modFrete>9</modFrete></transp>",
"<pag><detPag><tPag>01</tPag><vPag>150.00</vPag></detPag></pag>",
"</infNFe></NFe>",
);
let err = validate_xml(xml).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Chave de acesso"));
}
#[test]
fn remove_invalid_xml_chars_preserves_valid_text() {
assert_eq!(remove_invalid_xml_chars("Hello, World!"), "Hello, World!");
}
#[test]
fn remove_invalid_xml_chars_preserves_tab_lf_cr() {
assert_eq!(
remove_invalid_xml_chars("a\x09b\x0Ac\x0Dd"),
"a\x09b\x0Ac\x0Dd"
);
}
#[test]
fn remove_invalid_xml_chars_strips_null_and_low_controls() {
assert_eq!(
remove_invalid_xml_chars("\x00\x01\x02\x03\x04\x05\x06\x07\x08hello"),
"hello"
);
}
#[test]
fn remove_invalid_xml_chars_strips_0b_0c() {
assert_eq!(remove_invalid_xml_chars("a\x0Bb\x0Cc"), "abc");
}
#[test]
fn remove_invalid_xml_chars_strips_0e_to_1f() {
let mut input = String::from("ok");
for byte in 0x0Eu8..=0x1F {
input.push(byte as char);
}
input.push_str("end");
assert_eq!(remove_invalid_xml_chars(&input), "okend");
}
#[test]
fn remove_invalid_xml_chars_strips_del() {
assert_eq!(remove_invalid_xml_chars("a\x7Fb"), "a\x7Fb");
}
#[test]
fn remove_invalid_xml_chars_strips_fffe_ffff() {
let input = format!("a{}b{}c", '\u{FFFE}', '\u{FFFF}');
assert_eq!(remove_invalid_xml_chars(&input), "abc");
}
#[test]
fn remove_invalid_xml_chars_preserves_bmp_and_supplementary() {
assert_eq!(
remove_invalid_xml_chars("café résumé 日本語"),
"café résumé 日本語"
);
let input = "hello \u{1F600} world"; assert_eq!(remove_invalid_xml_chars(input), input);
}
#[test]
fn remove_invalid_xml_chars_preserves_private_use_area() {
let input = "a\u{E000}b\u{FFFD}c";
assert_eq!(remove_invalid_xml_chars(input), input);
}
#[test]
fn remove_invalid_xml_chars_empty_string() {
assert_eq!(remove_invalid_xml_chars(""), "");
}
#[test]
fn remove_invalid_xml_chars_all_invalid() {
assert_eq!(remove_invalid_xml_chars("\x00\x01\x02\x03"), "");
}
#[test]
fn remove_invalid_xml_chars_mixed_xml_content() {
let input = "<tag>val\x00ue with \x0Bcontrol\x1F chars</tag>";
assert_eq!(
remove_invalid_xml_chars(input),
"<tag>value with control chars</tag>"
);
}
#[test]
fn clear_xml_string_removes_whitespace_between_tags() {
let xml = "<root>\n <child>text</child>\n</root>";
assert_eq!(
clear_xml_string(xml, false),
"<root><child>text</child></root>"
);
}
#[test]
fn clear_xml_string_removes_tabs_cr_lf() {
let xml = "<a>\t<b>\r\n<c>val</c>\n</b>\n</a>";
assert_eq!(clear_xml_string(xml, false), "<a><b><c>val</c></b></a>");
}
#[test]
fn clear_xml_string_removes_default_namespace() {
let xml = "<Signature xmlns:default=\"http://www.w3.org/2000/09/xmldsig#\"><default:SignedInfo>data</default:SignedInfo></Signature>";
assert_eq!(
clear_xml_string(xml, false),
"<Signature ><SignedInfo>data</SignedInfo></Signature>"
);
}
#[test]
fn clear_xml_string_removes_standalone_no() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?><root/>";
assert_eq!(
clear_xml_string(xml, false),
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><root/>"
);
}
#[test]
fn clear_xml_string_removes_encoding_tag() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><a>1</a></root>";
assert_eq!(clear_xml_string(xml, true), "<root><a>1</a></root>");
}
#[test]
fn clear_xml_string_preserves_without_encoding_tag() {
let xml = "<?xml version=\"1.0\"?><root><a>1</a></root>";
assert_eq!(
clear_xml_string(xml, false),
"<?xml version=\"1.0\"?><root><a>1</a></root>"
);
}
#[test]
fn clear_xml_string_no_encoding_tag_present() {
let xml = "<root><a>1</a></root>";
assert_eq!(clear_xml_string(xml, true), "<root><a>1</a></root>");
}
#[test]
fn clear_xml_string_empty_input() {
assert_eq!(clear_xml_string("", false), "");
assert_eq!(clear_xml_string("", true), "");
}
#[test]
fn clear_xml_string_preserves_text_content_spaces() {
let xml = "<tag>hello world</tag>";
assert_eq!(clear_xml_string(xml, false), "<tag>hello world</tag>");
}
#[test]
fn clear_xml_string_collapses_multiple_spaces_between_tags() {
let xml = "<a> <b>text</b> </a>";
assert_eq!(clear_xml_string(xml, false), "<a><b>text</b></a>");
}
#[test]
fn clear_xml_string_removes_colon_default_suffix() {
let xml = "<Signature:default><data/></Signature:default>";
assert_eq!(
clear_xml_string(xml, false),
"<Signature><data/></Signature>"
);
}
#[test]
fn replace_unacceptable_empty() {
assert_eq!(replace_unacceptable_characters(""), "");
}
#[test]
fn replace_unacceptable_plain_text() {
assert_eq!(
replace_unacceptable_characters("Venda de mercadorias"),
"Venda de mercadorias"
);
}
#[test]
fn replace_unacceptable_removes_angle_brackets() {
assert_eq!(replace_unacceptable_characters("foo<bar>baz"), "foobarbaz");
}
#[test]
fn replace_unacceptable_ampersand_encoding() {
assert_eq!(replace_unacceptable_characters("A&B"), "A & B");
}
#[test]
fn replace_unacceptable_removes_quotes() {
assert_eq!(
replace_unacceptable_characters(r#"It's a "test""#),
"Its a test"
);
}
#[test]
fn replace_unacceptable_collapses_whitespace() {
assert_eq!(
replace_unacceptable_characters("hello world"),
"hello world"
);
}
#[test]
fn replace_unacceptable_trims() {
assert_eq!(replace_unacceptable_characters(" hello "), "hello");
}
#[test]
fn replace_unacceptable_removes_control_chars() {
assert_eq!(
replace_unacceptable_characters("abc\x00\x01\x02def"),
"abcdef"
);
}
#[test]
fn replace_unacceptable_removes_cr_lf_tab() {
assert_eq!(
replace_unacceptable_characters("line1\r\n\tline2"),
"line1 line2"
);
}
#[test]
fn replace_unacceptable_combined() {
assert_eq!(
replace_unacceptable_characters(
" Cancelamento <por> erro & \"duplicidade\" na emissão\t\n "
),
"Cancelamento por erro & duplicidade na emissão"
);
}
#[test]
fn replace_unacceptable_ampersand_already_spaced() {
assert_eq!(replace_unacceptable_characters("A & B"), "A & B");
}
#[test]
fn replace_unacceptable_multiple_ampersands() {
assert_eq!(
replace_unacceptable_characters("A&B&C"),
"A & B & C"
);
}
#[test]
fn replace_unacceptable_preserves_accented_chars() {
assert_eq!(
replace_unacceptable_characters("São Paulo — café"),
"São Paulo — café"
);
}
#[test]
fn replace_unacceptable_only_special_chars() {
assert_eq!(replace_unacceptable_characters("<>\"'"), "");
}
#[test]
fn replace_unacceptable_del_char() {
assert_eq!(replace_unacceptable_characters("abc\x7Fdef"), "abcdef");
}
#[test]
fn tag_content_from_string() {
let content: TagContent = String::from("hello").into();
match content {
TagContent::Text(t) => assert_eq!(t, "hello"),
_ => panic!("expected Text"),
}
}
#[test]
fn tag_content_from_vec_string() {
let content: TagContent = vec!["<a/>".to_string(), "<b/>".to_string()].into();
match content {
TagContent::Children(kids) => assert_eq!(kids.len(), 2),
_ => panic!("expected Children"),
}
}
#[test]
fn pretty_print_self_closing_tag() {
let xml = "<root><empty/></root>";
let pretty = pretty_print_xml(xml);
assert!(pretty.contains(" <empty/>"));
}
#[test]
fn pretty_print_standalone_text() {
let xml = "<root><a><b>text</b></a></root>";
let pretty = pretty_print_xml(xml);
assert!(pretty.contains(" <b>text</b>"));
}
#[test]
fn clear_xml_string_non_tag_after_whitespace() {
let xml = "<a>text after close</a>";
let result = clear_xml_string(xml, false);
assert_eq!(result, "<a>text after close</a>");
}
#[test]
fn delete_all_between_no_match() {
let result = delete_all_between("hello world", "<?xml", "?>");
assert_eq!(result, "hello world");
}
#[test]
fn delete_all_between_no_end_match() {
let result = delete_all_between("<?xml version start", "<?xml", "?>");
assert_eq!(result, "<?xml version start");
}
}