Skip to main content

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}