tiss_hash/lib.rs
1//! # tiss-hash
2//!
3//! Hash MD5 do epílogo `<ans:hash>` em XMLs do **Padrão TISS/ANS** (Padrão TISS
4//! — Troca de Informações em Saúde Suplementar, regulamentado pela
5//! Agência Nacional de Saúde Suplementar).
6//!
7//! Spec canônica: `docs/SPEC.md` no repositório principal.
8//! Implementação de referência: `conformance/reference.py` (Python + lxml).
9//! Esta crate **bate byte-a-byte** com a referência nos vetores em
10//! `conformance/vectors.json` (positivos comparam `expected_md5`; negativos
11//! exigem `Err`, ex.: múltiplos `<ans:hash>` e BOM UTF-16/UTF-32).
12//!
13//! ## Algoritmo (resumo)
14//!
15//! 1. Parse do XML.
16//! 2. Zerar o conteúdo de `<ans:hash>` (substituir por string vazia).
17//! 3. Concatenar o `.text` de cada **nó-folha** (elemento ou comentário sem
18//! filhos elemento/comentário/PI), em ordem de documento.
19//! 4. MD5 dos bytes **UTF-8** da string concatenada (não ISO-8859-1, apesar
20//! do manual TISS).
21//! 5. Hex lowercase, 32 caracteres.
22//!
23//! Ver `conformance/AMBIGUITY_NOTES.md` para o catálogo das 15 decisões
24//! canônicas (CDATA, entidades, atributos, comentários, etc.).
25//!
26//! ## Quickstart
27//!
28//! ```no_run
29//! use tiss_hash::{hash_tiss, hash_tiss_file};
30//!
31//! let raw = std::fs::read("envio.xml").unwrap();
32//! let digest = hash_tiss(&raw).unwrap();
33//! println!("{digest}"); // 32 chars hex lowercase
34//!
35//! // ou direto do arquivo
36//! let digest = hash_tiss_file("envio.xml").unwrap();
37//! ```
38//!
39//! ## Decisão de parser: roxmltree
40//!
41//! Avaliadas três opções:
42//!
43//! - **roxmltree** (escolhida) — parser DOM puro, API próxima do
44//! `ElementTree`/`lxml` do Python. Suporta iteração `descendants()` que
45//! inclui nós `Comment` (semântica idêntica à `lxml.iter()` da
46//! referência). Zero alloc além da árvore. Limitação: aceita só `&str`
47//! UTF-8, exige pré-decodificação ISO-8859-1 manual (feita aqui — mapping
48//! 1:1 byte → codepoint).
49//! - quick-xml — SAX/streaming, mais rápido em throughput, mas exige
50//! reconstruir manualmente o conceito de "folha" e tracking de pilha.
51//! Descartado por não ser necessário pra performance esperada (XMLs TISS
52//! geralmente < 5 MB).
53//! - xmltree — DOM básico, menos manutenção, sem `descendants()` ergonômico.
54//! Descartado.
55
56#![deny(missing_docs)]
57#![deny(unsafe_code)]
58#![warn(rust_2018_idioms)]
59
60use md5::{Digest, Md5};
61use std::fmt;
62use std::fs;
63use std::io;
64use std::path::Path;
65
66/// Namespace XML do Padrão TISS/ANS. Usado para localizar `<ans:hash>`.
67///
68/// Apesar do prefixo convencional ser `ans:`, o que conta é o **namespace
69/// URI**: qualquer prefixo serve, desde que mapeie pra esta URI.
70pub const TISS_NAMESPACE: &str = "http://www.ans.gov.br/padroes/tiss/schemas";
71
72/// Erros possíveis no cálculo do hash TISS.
73#[derive(Debug)]
74pub enum TissHashError {
75 /// XML mal-formado, contém DTD malicioso, ou viola política de
76 /// segurança (XXE, entidades externas). A mensagem traz o erro do
77 /// parser subjacente para diagnóstico.
78 InvalidXml(String),
79 /// Erro de I/O ao ler arquivo (somente em `hash_tiss_file`).
80 Io(io::Error),
81}
82
83impl fmt::Display for TissHashError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 Self::InvalidXml(msg) => write!(f, "XML inválido para hash TISS: {msg}"),
87 Self::Io(err) => write!(f, "erro de I/O ao ler XML TISS: {err}"),
88 }
89 }
90}
91
92impl std::error::Error for TissHashError {
93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94 match self {
95 Self::InvalidXml(_) => None,
96 Self::Io(err) => Some(err),
97 }
98 }
99}
100
101impl From<io::Error> for TissHashError {
102 fn from(err: io::Error) -> Self {
103 Self::Io(err)
104 }
105}
106
107impl From<roxmltree::Error> for TissHashError {
108 fn from(err: roxmltree::Error) -> Self {
109 Self::InvalidXml(err.to_string())
110 }
111}
112
113/// Calcula o hash MD5 canônico do epílogo TISS/ANS a partir dos bytes do XML.
114///
115/// Retorna uma string hex de **32 caracteres minúsculos** (lowercase).
116///
117/// # Parâmetros
118///
119/// - `xml`: bytes do arquivo XML completo (pode declarar `encoding="iso-8859-1"`
120/// ou `encoding="utf-8"`, e pode começar com BOM UTF-8 — todos suportados).
121///
122/// # Erros
123///
124/// - [`TissHashError::InvalidXml`] se o parser rejeitar a entrada.
125///
126/// # Exemplo
127///
128/// ```no_run
129/// let raw = std::fs::read("envio.xml").unwrap();
130/// let digest = tiss_hash::hash_tiss(&raw).unwrap();
131/// assert_eq!(digest.len(), 32);
132/// ```
133pub fn hash_tiss(xml: &[u8]) -> Result<String, TissHashError> {
134 // Rejeição por BOM fora de escopo: o escopo é ISO-8859-1 + UTF-8.
135 // UTF-32 ANTES de UTF-16, pois o BOM UTF-32 LE (FF FE 00 00) começa
136 // com os mesmos 2 bytes do UTF-16 LE (FF FE) — checar o mais longo
137 // primeiro evita classificar erroneamente.
138 if let Some(enc) = detect_unsupported_bom(xml) {
139 return Err(TissHashError::InvalidXml(format!(
140 "encoding {enc} fora de escopo (suportado: ISO-8859-1, UTF-8)"
141 )));
142 }
143
144 let utf8 = decode_to_utf8(xml);
145 // ParsingOptions padrão: roxmltree não resolve entidades externas
146 // (não suporta), DTDs externos são ignorados — política segura por
147 // construção.
148 let opts = roxmltree::ParsingOptions {
149 allow_dtd: true,
150 ..Default::default()
151 };
152 let doc = roxmltree::Document::parse_with_options(&utf8, opts)?;
153 let root = doc.root_element();
154
155 // Localizar o(s) <ans:hash> (qualquer prefixo, namespace TISS).
156 // TISS define no máximo 1; >1 é documento inválido e deve ser rejeitado.
157 let hash_node_id = find_hash_node(root)?;
158
159 // Concat dos textos de folhas em ordem de documento, zerando <ans:hash>.
160 let mut buf = String::new();
161 for node in root.descendants() {
162 if !is_leaf_for_hash(node) {
163 continue;
164 }
165 // Zerar conteúdo de <ans:hash>: pular o .text (equivale a "").
166 if Some(node.id()) == hash_node_id {
167 continue;
168 }
169 if let Some(t) = node.text() {
170 buf.push_str(t);
171 }
172 }
173
174 let mut hasher = Md5::new();
175 hasher.update(buf.as_bytes());
176 let digest = hasher.finalize();
177 Ok(hex_lower(&digest))
178}
179
180/// Atalho: lê o arquivo do disco e calcula [`hash_tiss`].
181///
182/// # Erros
183///
184/// - [`TissHashError::Io`] em falha de I/O.
185/// - [`TissHashError::InvalidXml`] se o parser rejeitar o conteúdo.
186pub fn hash_tiss_file<P: AsRef<Path>>(path: P) -> Result<String, TissHashError> {
187 let raw = fs::read(path)?;
188 hash_tiss(&raw)
189}
190
191// -- Internos --------------------------------------------------------------
192
193/// Decide se um nó é "folha pro hash":
194///
195/// - Aceita nós `Element` e `Comment` (PI, Text, Root são pulados ou
196/// tratados naturalmente pela iteração).
197/// - "Sem filhos" no sentido da referência `lxml`: sem children
198/// `Element`/`Comment`/`PI`. Children `Text` NÃO contam (TISS não tem
199/// conteúdo misto, então um elemento com só Text dentro é folha de
200/// valor).
201fn is_leaf_for_hash(n: roxmltree::Node<'_, '_>) -> bool {
202 if !(n.is_element() || n.is_comment()) {
203 return false;
204 }
205 !n.children()
206 .any(|c| c.is_element() || c.is_comment() || c.is_pi())
207}
208
209/// Localiza o `<ans:hash>` (namespace TISS, qualquer prefixo) por id de nó.
210///
211/// Casa por **URI de namespace**, não por prefixo literal (`local == "hash"`
212/// e `namespace == TISS_NAMESPACE`), então o namespace default casa.
213///
214/// # Erros
215///
216/// - [`TissHashError::InvalidXml`] se houver **mais de um** `<ans:hash>`:
217/// o Padrão TISS define exatamente um epílogo, então `>1` é documento
218/// inválido (ver `AMBIGUITY_NOTES.md` §9). Zero é válido (caminho "hash
219/// ausente" — concatena tudo).
220fn find_hash_node(
221 root: roxmltree::Node<'_, '_>,
222) -> Result<Option<roxmltree::NodeId>, TissHashError> {
223 let mut found: Option<roxmltree::NodeId> = None;
224 let mut count = 0usize;
225 for n in root.descendants() {
226 if n.is_element()
227 && n.tag_name().name() == "hash"
228 && n.tag_name().namespace() == Some(TISS_NAMESPACE)
229 {
230 count += 1;
231 if found.is_none() {
232 found = Some(n.id());
233 }
234 }
235 }
236 if count > 1 {
237 return Err(TissHashError::InvalidXml(format!(
238 "múltiplos elementos <hash> do namespace TISS (encontrados {count}, esperado no máximo 1)"
239 )));
240 }
241 Ok(found)
242}
243
244/// Detecta BOM de encoding fora de escopo (UTF-16 / UTF-32).
245///
246/// Ordem importa: UTF-32 ANTES de UTF-16, pois o BOM UTF-32 LE
247/// (`FF FE 00 00`) começa com os mesmos 2 bytes do UTF-16 LE (`FF FE`).
248/// Retorna o rótulo do encoding detectado, ou `None` se nenhum BOM
249/// fora de escopo estiver presente (UTF-8 BOM é tratado depois, em
250/// `decode_to_utf8`).
251fn detect_unsupported_bom(raw: &[u8]) -> Option<&'static str> {
252 if raw.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) || raw.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) {
253 Some("UTF-32")
254 } else if raw.starts_with(&[0xFF, 0xFE]) || raw.starts_with(&[0xFE, 0xFF]) {
255 Some("UTF-16")
256 } else {
257 None
258 }
259}
260
261/// Decodifica bytes XML para `String` UTF-8 compatível com `roxmltree`.
262///
263/// Roxmltree exige `&str` UTF-8 (não aceita ISO-8859-1 nativamente). Esta
264/// função:
265///
266/// 1. Strippa BOM UTF-8 (`EF BB BF`) se presente.
267/// 2. Detecta declaração `encoding="iso-8859-1"` no prólogo.
268/// 3. Se ISO-8859-1: mapeia cada byte para o codepoint Unicode (byte `n`
269/// → `U+00n`), que é o mapping correto e bijetivo (ISO-8859-1 é
270/// subset de Unicode no range 0x00..=0xFF). Reescreve a declaração
271/// para `encoding="utf-8"` para o parser não brigar.
272/// 4. Caso contrário: assume UTF-8 e tenta `from_utf8_lossy`. Se houver
273/// bytes inválidos, eles viram U+FFFD — o parser provavelmente vai
274/// falhar, e nós devolvemos `InvalidXml` no caller.
275fn decode_to_utf8(raw: &[u8]) -> String {
276 let bytes = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) {
277 &raw[3..]
278 } else {
279 raw
280 };
281
282 // Detectar encoding declarado (prólogo, primeiros ~200 bytes ASCII).
283 let head_len = bytes.len().min(200);
284 let head_lower: String = bytes[..head_len]
285 .iter()
286 .map(|&b| b.to_ascii_lowercase() as char)
287 .collect();
288 let is_iso = head_lower.contains("encoding=\"iso-8859-1\"")
289 || head_lower.contains("encoding='iso-8859-1'");
290
291 if is_iso {
292 // ISO-8859-1 → Unicode: cada byte vira o codepoint correspondente.
293 let s: String = bytes.iter().map(|&b| b as char).collect();
294 // Reescrever declaração pra UTF-8 (o conteúdo agora é UTF-8 válido).
295 s.replacen("encoding='iso-8859-1'", "encoding='utf-8'", 1)
296 .replacen("encoding=\"iso-8859-1\"", "encoding=\"utf-8\"", 1)
297 } else {
298 // Assume UTF-8 (ou ASCII puro). `from_utf8_lossy` cobre o caso de
299 // bytes mal-formados sem panic; o parser falhará logo depois com
300 // erro tipado.
301 String::from_utf8_lossy(bytes).into_owned()
302 }
303}
304
305/// Serializa 16 bytes do digest MD5 em hex lowercase (32 chars).
306fn hex_lower(digest: &[u8]) -> String {
307 const HEX: &[u8; 16] = b"0123456789abcdef";
308 let mut out = String::with_capacity(digest.len() * 2);
309 for &b in digest {
310 out.push(HEX[(b >> 4) as usize] as char);
311 out.push(HEX[(b & 0x0F) as usize] as char);
312 }
313 out
314}
315
316// -- Testes unitários ------------------------------------------------------
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn hex_lower_zera() {
324 assert_eq!(hex_lower(&[0u8; 16]), "00000000000000000000000000000000");
325 }
326
327 #[test]
328 fn hex_lower_ff() {
329 assert_eq!(hex_lower(&[0xFFu8; 16]), "ffffffffffffffffffffffffffffffff");
330 }
331
332 #[test]
333 fn hex_lower_mix() {
334 assert_eq!(hex_lower(&[0xDE, 0xAD, 0xBE, 0xEF]), "deadbeef");
335 }
336
337 #[test]
338 fn md5_string_vazia() {
339 // MD5("") = d41d8cd98f00b204e9800998ecf8427e
340 let mut h = Md5::new();
341 h.update(b"");
342 assert_eq!(hex_lower(&h.finalize()), "d41d8cd98f00b204e9800998ecf8427e");
343 }
344
345 #[test]
346 fn decode_utf8_strippa_bom() {
347 let raw = b"\xEF\xBB\xBF<?xml version='1.0' encoding='utf-8'?><a/>";
348 let s = decode_to_utf8(raw);
349 assert!(s.starts_with("<?xml"));
350 }
351
352 #[test]
353 fn decode_iso_reescreve_decl() {
354 let mut raw: Vec<u8> = b"<?xml version='1.0' encoding='iso-8859-1'?><a>".to_vec();
355 raw.push(0xC9); // É em ISO-8859-1
356 raw.extend_from_slice(b"</a>");
357 let s = decode_to_utf8(&raw);
358 assert!(s.contains("encoding='utf-8'"));
359 assert!(s.contains('É')); // U+00C9 (= byte 0xC9 mapeado)
360 }
361
362 #[test]
363 fn xml_invalido_retorna_erro() {
364 let r = hash_tiss(b"<no-encoding><sem-fechar>");
365 assert!(matches!(r, Err(TissHashError::InvalidXml(_))));
366 }
367
368 #[test]
369 fn multiplos_hash_rejeitado() {
370 let xml = b"<?xml version='1.0' encoding='utf-8'?>\
371 <ans:mensagemTISS xmlns:ans=\"http://www.ans.gov.br/padroes/tiss/schemas\">\
372 <ans:epilogo><ans:hash>A</ans:hash><ans:hash>B</ans:hash></ans:epilogo>\
373 </ans:mensagemTISS>";
374 assert!(matches!(hash_tiss(xml), Err(TissHashError::InvalidXml(_))));
375 }
376
377 #[test]
378 fn um_hash_aceito() {
379 // Sanidade: exatamente 1 <ans:hash> não dispara a rejeição.
380 let xml = b"<?xml version='1.0' encoding='utf-8'?>\
381 <ans:mensagemTISS xmlns:ans=\"http://www.ans.gov.br/padroes/tiss/schemas\">\
382 <ans:epilogo><ans:hash>X</ans:hash></ans:epilogo>\
383 </ans:mensagemTISS>";
384 assert!(hash_tiss(xml).is_ok());
385 }
386
387 #[test]
388 fn sem_hash_aceito() {
389 // Documento sem <ans:hash> é válido (concatena tudo, sem erro).
390 let xml = b"<?xml version='1.0' encoding='utf-8'?>\
391 <ans:mensagemTISS xmlns:ans=\"http://www.ans.gov.br/padroes/tiss/schemas\">\
392 <ans:guia><ans:valor>42</ans:valor></ans:guia>\
393 </ans:mensagemTISS>";
394 assert!(hash_tiss(xml).is_ok());
395 }
396
397 #[test]
398 fn bom_utf16_le_rejeitado() {
399 assert_eq!(
400 detect_unsupported_bom(&[0xFF, 0xFE, 0x3C, 0x00]),
401 Some("UTF-16")
402 );
403 let r = hash_tiss(&[0xFF, 0xFE, 0x3C, 0x00]);
404 assert!(matches!(r, Err(TissHashError::InvalidXml(_))));
405 }
406
407 #[test]
408 fn bom_utf16_be_rejeitado() {
409 assert_eq!(
410 detect_unsupported_bom(&[0xFE, 0xFF, 0x00, 0x3C]),
411 Some("UTF-16")
412 );
413 let r = hash_tiss(&[0xFE, 0xFF, 0x00, 0x3C]);
414 assert!(matches!(r, Err(TissHashError::InvalidXml(_))));
415 }
416
417 #[test]
418 fn bom_utf32_le_rejeitado() {
419 // FF FE 00 00 deve classificar como UTF-32, não UTF-16 (ordem!).
420 assert_eq!(
421 detect_unsupported_bom(&[0xFF, 0xFE, 0x00, 0x00]),
422 Some("UTF-32")
423 );
424 }
425
426 #[test]
427 fn bom_utf32_be_rejeitado() {
428 assert_eq!(
429 detect_unsupported_bom(&[0x00, 0x00, 0xFE, 0xFF]),
430 Some("UTF-32")
431 );
432 }
433
434 #[test]
435 fn sem_bom_passa() {
436 // UTF-8 BOM (EF BB BF) NÃO é fora de escopo; tratado em decode.
437 assert_eq!(detect_unsupported_bom(&[0xEF, 0xBB, 0xBF, 0x3C]), None);
438 assert_eq!(detect_unsupported_bom(b"<a/>"), None);
439 }
440
441 #[test]
442 fn hash_mensagem_minima_inline() {
443 // XML mínimo: hash zerado + sem texto = MD5("") = d41d8cd9...
444 let xml = b"<?xml version='1.0' encoding='utf-8'?>\
445 <ans:mensagemTISS xmlns:ans=\"http://www.ans.gov.br/padroes/tiss/schemas\">\
446 <ans:epilogo><ans:hash>QUALQUER</ans:hash></ans:epilogo>\
447 </ans:mensagemTISS>";
448 let h = hash_tiss(xml).unwrap();
449 assert_eq!(h, "d41d8cd98f00b204e9800998ecf8427e");
450 }
451}