es3 0.1.0

Library for parsing, extracting, and verifying ES3 dossier files
Documentation
use es3::{Dossier, Transform, VerificationOptions};

const ES3_WITH_SIGNATURE_OBJECT: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<e:Dossier xmlns:e="https://www.microsec.hu/ds/e-szigno30#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <e:DossierProfile Id="Profile0" OBJREF="#Object0">
    <e:Title>Test dossier</e:Title>
    <e:CreationDate>2026-05-16T00:00:00Z</e:CreationDate>
  </e:DossierProfile>
  <e:Documents Id="Object0">
    <e:Document>
      <e:DocumentProfile Id="Profile1" OBJREF="#Payload1">
        <e:Title>Invoice 1</e:Title>
        <e:CreationDate>2026-05-16T00:00:00Z</e:CreationDate>
        <e:Format><e:MIME-Type type="text" subtype="plain" extension="txt" charset="utf-8"/></e:Format>
        <e:SourceSize sizeValue="11" sizeUnit="B"/>
        <e:BaseTransform><e:Transform Algorithm="base64"/></e:BaseTransform>
      </e:DocumentProfile>
      <ds:Object Id="Payload1">SGVsbG8gd29ybGQ=</ds:Object>
      <ds:Signature Id="Sig1"><ds:Object Id="SignatureObject">not a payload</ds:Object></ds:Signature>
      <e:TimeStamp>timestamp</e:TimeStamp>
    </e:Document>
  </e:Documents>
  <ds:Signature Id="FrameSig"><ds:Object Id="FrameObject">frame signature object</ds:Object></ds:Signature>
  <e:TimeStamp>frame timestamp</e:TimeStamp>
</e:Dossier>"##;

fn es3_with_payload(payload: &str) -> String {
    ES3_WITH_SIGNATURE_OBJECT.replace("SGVsbG8gd29ybGQ=", payload)
}

fn es3_with_payload_children(children: &str) -> String {
    ES3_WITH_SIGNATURE_OBJECT.replace("SGVsbG8gd29ybGQ=", children)
}

fn oversized_payload_text() -> String {
    "A".repeat(16_777_220)
}

fn assert_public_parse_success(dossier: Dossier) {
    assert_eq!(dossier.documents()[0].title(), "Invoice 1");
    assert_eq!(dossier.source_xml(), ES3_WITH_SIGNATURE_OBJECT);
}

#[test]
fn lists_document_metadata_and_ignores_signature_objects() {
    let dossier = ES3_WITH_SIGNATURE_OBJECT.parse::<Dossier>().unwrap();
    let documents = dossier.documents();

    assert_eq!(documents.len(), 1);
    assert_eq!(documents[0].index(), 0);
    assert_eq!(documents[0].title(), "Invoice 1");
    assert_eq!(documents[0].source_size(), 11);
    assert_eq!(documents[0].transforms(), &[Transform::Base64]);
    assert_eq!(documents[0].signature_count(), 1);
    assert_eq!(documents[0].timestamp_count(), 1);
    assert_eq!(dossier.dossier_signature_count(), 1);
    assert_eq!(dossier.dossier_timestamp_count(), 1);
    assert_eq!(documents[0].mime_type().top_level_type(), "text");
    assert_eq!(documents[0].mime_type().subtype(), "plain");
    assert_eq!(documents[0].mime_type().extension(), Some("txt"));
    assert_eq!(documents[0].mime_type().charset(), Some("utf-8"));
}

#[test]
fn verifies_structure_before_materializing_dossier() {
    let report = es3::verify_str("<not-es3/>", VerificationOptions::without_signatures());

    assert!(!report.structure.is_ok());
    assert_eq!(
        report.structure.errors[0].message,
        "root element must be es:Dossier in the ES3 namespace"
    );
}

#[test]
fn dossier_parse_runs_validation() {
    let dossier = ES3_WITH_SIGNATURE_OBJECT.parse::<Dossier>().unwrap();

    assert_eq!(dossier.documents()[0].title(), "Invoice 1");

    let error = "<not-es3/>".parse::<Dossier>().unwrap_err();

    assert_eq!(
        error.to_string(),
        "root element must be es:Dossier in the ES3 namespace"
    );
}

#[test]
fn parses_public_success_adapters() {
    assert_public_parse_success(ES3_WITH_SIGNATURE_OBJECT.parse::<Dossier>().unwrap());
    assert_public_parse_success(Dossier::from_bytes(ES3_WITH_SIGNATURE_OBJECT.as_bytes()).unwrap());
    assert_public_parse_success(Dossier::try_from(ES3_WITH_SIGNATURE_OBJECT.as_bytes()).unwrap());
    assert_public_parse_success(Dossier::try_from(ES3_WITH_SIGNATURE_OBJECT).unwrap());
    assert_public_parse_success(
        Dossier::from_reader(std::io::Cursor::new(ES3_WITH_SIGNATURE_OBJECT.as_bytes())).unwrap(),
    );
}

#[test]
fn from_bytes_rejects_non_utf8_input() {
    let error = Dossier::from_bytes(&[0xff, b'<', b'e', b's']).unwrap_err();

    assert!(
        error.to_string().contains("ES3 XML is not valid UTF-8"),
        "expected UTF-8 error, got {error:?}"
    );
}

#[test]
fn from_reader_reports_path_free_read_errors() {
    struct BrokenReader;

    impl std::io::Read for BrokenReader {
        fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
            Err(std::io::Error::new(
                std::io::ErrorKind::Other,
                "reader failed",
            ))
        }
    }

    let error = Dossier::from_reader(BrokenReader).unwrap_err();

    assert!(
        error.to_string().contains("failed to read ES3 XML input"),
        "expected path-free read error, got {error:?}"
    );
    assert!(
        !error.to_string().contains("sample.es3"),
        "reader error must not invent a filesystem path: {error:?}"
    );
}

#[test]
fn documents_iter_borrows_document_entries() {
    let dossier = ES3_WITH_SIGNATURE_OBJECT.parse::<Dossier>().unwrap();
    let titles = dossier
        .documents_iter()
        .map(|document| document.title())
        .collect::<Vec<_>>();

    assert_eq!(titles, vec!["Invoice 1"]);
}

#[test]
fn accepts_xsd_charset_spelling() {
    let xml = ES3_WITH_SIGNATURE_OBJECT.replace("charset=\"utf-8\"", "charSet=\"iso-8859-2\"");
    let dossier = xml.parse::<Dossier>().unwrap();
    assert_eq!(
        dossier.documents()[0].mime_type().charset(),
        Some("iso-8859-2")
    );
}

#[test]
fn parses_schema_specific_dossier_namespace() {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<es:Dossier xmlns="http://uri.etsi.org/01903/v1.3.2#" xmlns:es="http://www.e-cegjegyzek.hu/2023/e-cegeljaras#" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
  <es:DossierProfile Id="PObject0" OBJREF="Object0">
    <es:Title>Schema specific dossier</es:Title>
    <es:CreationDate>2026-05-13T12:53:21Z</es:CreationDate>
  </es:DossierProfile>
  <es:Documents Id="Object0">
    <es:Document>
      <es:DocumentProfile Id="Profile1" OBJREF="Payload1">
        <es:Title>A létesítő okirat</es:Title>
        <es:CreationDate>2026-05-13T12:53:56Z</es:CreationDate>
        <es:Format><es:MIME-Type type="application" subtype="pdf" extension="pdf"/></es:Format>
        <es:SourceSize sizeValue="11" sizeUnit="B"/>
        <es:BaseTransform><es:Transform Algorithm="base64"/></es:BaseTransform>
      </es:DocumentProfile>
      <ds:Object Id="Payload1">SGVsbG8gd29ybGQ=</ds:Object>
    </es:Document>
  </es:Documents>
</es:Dossier>"#;

    let dossier = xml.parse::<Dossier>().unwrap();
    let documents = dossier.documents();

    assert_eq!(documents.len(), 1);
    assert_eq!(documents[0].title(), "A létesítő okirat");
    assert_eq!(documents[0].mime_type().extension(), Some("pdf"));
}

#[test]
fn parser_rejects_xml_over_node_limit_before_building_dossier() {
    let payload_children = (0..5_000).map(|_| "<x/>").collect::<String>();
    let xml = es3_with_payload_children(&payload_children);

    let error = xml.parse::<Dossier>().unwrap_err();

    assert!(
        error.to_string().contains("nodes limit reached"),
        "expected parser node-limit error, got {error:?}"
    );
}

#[test]
fn structural_verification_rejects_oversized_base64_payload_text() {
    let payload = oversized_payload_text();
    let xml = es3_with_payload(&payload);

    let report = es3::verify_structure_str(&xml);

    assert!(!report.is_ok(), "{report:#?}");
    assert!(
        report.errors.iter().any(|finding| finding
            .message
            .contains("document payload text is too large")),
        "{report:#?}"
    );
}

#[test]
fn parser_rejects_xml_over_attribute_marker_limit_before_building_dossier() {
    let attributes = (0..5_000)
        .map(|index| format!(r#" a{index}="value""#))
        .collect::<String>();
    let xml = ES3_WITH_SIGNATURE_OBJECT.replace("<e:Dossier ", &format!("<e:Dossier{attributes} "));

    let error = xml.parse::<Dossier>().unwrap_err();

    assert!(
        error
            .to_string()
            .contains("XML parser attribute marker limit reached"),
        "expected parser attribute marker error, got {error:?}"
    );
}

#[test]
fn dossier_parse_rejects_oversized_split_payload_text() {
    let payload = format!("A<!-- split -->{}", oversized_payload_text());
    let xml = es3_with_payload(&payload);

    let error = xml.parse::<Dossier>().unwrap_err();

    assert!(
        error
            .to_string()
            .contains("document payload text is too large"),
        "expected split payload text limit error, got {error:?}"
    );
}