vole-document 0.1.0-alpha.16

Persistent procedural document runtime: byte-exact reconstruction plus a content-addressed procedural seed DAG, queryable observations with provenance, and selective late materialization.
Documentation
//! Phase 12.13 — hostile-input security court (library level).
//!
//! This drives the Phase-12 parser surfaces directly over the committed hostile
//! corpus in `tests/fixtures/phase12-hostile/` and asserts the universal
//! hostile-input invariants of `SECURITY.md` and research I §8–§10:
//!
//! 1. **Never** an `InternalInvariant` on malformed input — every failure is a
//!    typed class.
//! 2. A ZIP scan that succeeds is a complete, contiguous, non-overlapping cover
//!    of `[0, N)` (`ZipPhysical::validate`) that re-emits the input exactly.
//! 3. The opaque floor preserves arbitrary hostile bytes exactly
//!    (`encode` → `materialize` byte-equal).
//! 4. The XML policy refuses DOCTYPE/NUL/non-UTF-8/UTF-16 and over-deep nesting
//!    with typed errors, never a panic.
//! 5. The structural threat list (research I §1) is *rejected* typed: cover
//!    faults, ZIP64 contradictions, multi-disk, name hazards, duplicates.
//!
//! The corpus is generated by `tools/fixtures/phase12-hostile-gen.py` (Python
//! stdlib only) and committed; this test is the runnable, dependency-free half
//! of the Phase-12.13 security court. The CLI half lives in
//! `tools/phase12-security-court.sh`.

#![cfg(all(feature = "docx", feature = "epub"))]

use std::path::PathBuf;

use vole_document::Limits;
use vole_document::adapter::docx;
use vole_document::adapter::epub;
use vole_document::adapter::package::opc;
use vole_document::adapter::package::{require_safe_name, scan};
use vole_document::error::{Error, ErrorClass};
use vole_document::field::capabilities::capabilities_for_format;
use vole_document::field::document_format::detect_document_format;
use vole_document::field::opc::build_opc_model;

fn fixture_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/phase12-hostile")
}

/// Every committed hostile fixture as `(name, bytes)`.
fn fixtures() -> Vec<(String, Vec<u8>)> {
    let dir = fixture_dir();
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
    for entry in std::fs::read_dir(&dir).expect("hostile fixture dir") {
        let path = entry.expect("dir entry").path();
        if path.extension().and_then(|e| e.to_str()) == Some("json") {
            continue;
        }
        let name = path
            .file_name()
            .and_then(|n| n.to_str())
            .expect("fixture name")
            .to_string();
        let bytes = std::fs::read(&path).expect("read fixture");
        out.push((name, bytes));
    }
    out.sort_by(|a, b| a.0.cmp(&b.0));
    assert!(
        out.len() >= 40,
        "expected the full hostile corpus, found {}",
        out.len()
    );
    out
}

/// Assert a fallible result is a typed failure (never an internal invariant).
fn typed_or_ok<T>(label: &str, r: vole_document::Result<T>) {
    if let Err(e) = r {
        assert_ne!(
            e.class(),
            ErrorClass::InternalInvariant,
            "[{label}] malformed input produced an internal invariant: {e}"
        );
    }
}

fn class(r: vole_document::Result<impl Sized>) -> Option<ErrorClass> {
    r.err().map(|e: Error| e.class())
}

/// Invariant 1+2: the ZIP scanner is cover-exact and typed on every fixture,
/// under both the tight STRICT and generous DEFAULT caps.
#[test]
fn zip_scan_is_cover_exact_and_typed() {
    for (name, bytes) in fixtures() {
        for limits in [Limits::STRICT, Limits::DEFAULT] {
            match scan(&bytes, limits) {
                Ok(physical) => {
                    physical
                        .validate(bytes.len() as u64)
                        .unwrap_or_else(|e| panic!("[{name}] invalid cover: {e}"));
                    assert_eq!(
                        physical.total_len(),
                        bytes.len() as u64,
                        "[{name}] cover total disagrees with the input length"
                    );
                    physical
                        .reemits(&bytes)
                        .unwrap_or_else(|e| panic!("[{name}] cover does not re-emit: {e}"));
                }
                Err(e) => assert_ne!(
                    e.class(),
                    ErrorClass::InternalInvariant,
                    "[{name}] scan reported an internal invariant"
                ),
            }
        }
    }
}

/// Invariant 1: every Phase-12 parser surface is typed on every fixture.
#[test]
fn every_parser_surface_is_typed() {
    for (name, bytes) in fixtures() {
        typed_or_ok(
            &format!("{name}/opc"),
            build_opc_model(&bytes, Limits::STRICT),
        );
        typed_or_ok(
            &format!("{name}/docx"),
            docx::build_docx_model(&bytes, Limits::STRICT),
        );
        typed_or_ok(
            &format!("{name}/epub"),
            epub::build_epub_model(&bytes, Limits::STRICT),
        );
        // Detection and capability discovery must never panic on hostile bytes.
        let fmt = detect_document_format(&bytes, Limits::STRICT);
        let caps = capabilities_for_format(fmt);
        assert!(
            !caps.to_json().is_empty(),
            "[{name}] capabilities JSON is empty"
        );
    }
}

/// Invariant 3: the opaque floor preserves arbitrary hostile bytes exactly.
#[test]
fn hostile_bytes_materialize_exactly_through_the_opaque_floor() {
    for (name, bytes) in fixtures() {
        let (descriptor, _) =
            vole_document::encode::encode(&bytes, Limits::DEFAULT).expect("encode hostile bytes");
        let (out, _) = vole_document::materialize::decode_to_bytes(&descriptor, Limits::DEFAULT)
            .expect("decode hostile descriptor");
        assert_eq!(out, bytes, "[{name}] opaque floor did not preserve bytes");
    }
}

/// Invariant 5: the structural ZIP threat list is rejected, typed.
#[test]
fn structural_zip_faults_are_rejected_typed() {
    // research I §1 (Z4/Z5/Z7/Z8/Z10/Z15): cover/identity is broken → reject.
    let structural = [
        "z_truncated.zip",
        "z_no_eocd.zip",
        "z_bad_eocd_comment.zip",
        "z_bad_cd_offset.zip",
        "z_zip64_inconsistent.zip",
        "z_multidisk.zip",
        "z_overlap.zip",
        "z_bad_descriptor.zip",
    ];
    // resource faults reject decode (ResourceLimit under at least one cap set).
    let resource = ["z_ratio_bomb.zip", "z_huge_declared.zip"];
    let allowed = [
        ErrorClass::InvalidZipStructure,
        ErrorClass::CoverageViolation,
        ErrorClass::UnsupportedFeature,
        ErrorClass::ResourceLimit,
    ];
    for (name, bytes) in fixtures() {
        let is_structural = structural.contains(&name.as_str());
        let is_resource = resource.contains(&name.as_str());
        if !is_structural && !is_resource {
            continue;
        }
        let cls = class(scan(&bytes, Limits::STRICT));
        match cls {
            Some(c) => assert!(allowed.contains(&c), "[{name}] unexpected scan class {c:?}"),
            None => panic!("[{name}] structural fault was accepted by scan"),
        }
    }
}

/// Invariant 5: hostile member names never become an identity or a path.
#[test]
fn name_hazards_are_rejected_and_never_become_paths() {
    let hazards = [
        "z_traversal_name.zip",
        "z_absolute_name.zip",
        "z_drive_name.zip",
        "z_backslash_name.zip",
        "z_nul_name.zip",
    ];
    for (name, bytes) in fixtures() {
        if !hazards.contains(&name.as_str()) {
            continue;
        }
        // The scanner preserves the raw name; the identity layer must reject it.
        let physical = scan(&bytes, Limits::STRICT).expect("scan");
        assert!(!physical.members.is_empty(), "[{name}] no members");
        for member in &physical.members {
            assert!(
                require_safe_name(&member.name).is_err(),
                "[{name}] unsafe name {:?} was accepted",
                String::from_utf8_lossy(&member.name)
            );
        }
        // The OPC part-name grammar rejects it (and never yields a PathBuf).
        let cls = class(build_opc_model(&bytes, Limits::STRICT));
        assert_eq!(
            cls,
            Some(ErrorClass::InvalidPackageStructure),
            "[{name}] expected an InvalidPackageStructure rejection"
        );
    }
}

/// Invariant 5: ZIP/OPC identity ambiguity and XML faults inside a package are
/// typed declines, never a last-wins guess.
#[test]
fn package_identity_and_xml_faults_are_typed() {
    // Each name must be refused by the OPC or format model with a package/XML
    // class — never accepted, never an internal invariant.
    let reject = [
        "z_duplicate_names.zip",
        "docx_bad_content_types.docx",
        "docx_missing_main_rel.docx",
        "docx_ambiguous_main.docx",
        "docx_duplicate_part.docx",
        "docx_traversal_part.docx",
        "epub_bad_container.epub",
        "epub_missing_opf.epub",
        "epub_bad_opf.epub",
    ];
    let allowed = [
        ErrorClass::InvalidPackageStructure,
        ErrorClass::InvalidXmlStructure,
        ErrorClass::InvalidZipStructure,
    ];
    for (name, bytes) in fixtures() {
        if !reject.contains(&name.as_str()) {
            continue;
        }
        // OPC faults surface through the OPC model; DOCX/EPUB faults through the
        // format model. At least one must be a typed package/XML rejection.
        let got = class(build_opc_model(&bytes, Limits::STRICT))
            .or_else(|| class(docx::build_docx_model(&bytes, Limits::STRICT)))
            .or_else(|| class(epub::build_epub_model(&bytes, Limits::STRICT)));
        match got {
            Some(c) => assert!(allowed.contains(&c), "[{name}] unexpected class {c:?}"),
            None => panic!("[{name}] package fault was accepted"),
        }
    }
}

// ---------------------------------------------------------------------------
// XML hardening (research I §2) — direct, store-free.
// ---------------------------------------------------------------------------

const CT_OK: &[u8] =
    b"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"/>";

#[test]
fn xml_doctype_is_refused() {
    let xml = b"<?xml version=\"1.0\"?><!DOCTYPE Types [<!ENTITY x \"y\">]><Types/>";
    assert_eq!(
        class(opc::parse_content_types(xml, Limits::STRICT)),
        Some(ErrorClass::InvalidXmlStructure)
    );
}

#[test]
fn xml_nul_and_non_utf8_are_refused() {
    let mut nul = CT_OK.to_vec();
    nul.push(0);
    assert_eq!(
        class(opc::parse_content_types(&nul, Limits::STRICT)),
        Some(ErrorClass::InvalidXmlStructure)
    );
    let bad = b"<Types>\xff\xfe</Types>";
    assert_eq!(
        class(opc::parse_content_types(bad, Limits::STRICT)),
        Some(ErrorClass::InvalidXmlStructure)
    );
}

#[test]
fn xml_utf16_bom_is_refused() {
    let mut bom = vec![0xFF, 0xFE];
    bom.extend_from_slice(b"<\0T\0>\0");
    assert_eq!(
        class(opc::parse_content_types(&bom, Limits::STRICT)),
        Some(ErrorClass::InvalidXmlStructure)
    );
}

#[test]
fn xml_depth_bomb_is_refused_typed() {
    // Deeper than the STRICT max_xml_depth (64).
    let mut xml = String::from("<Types>");
    for _ in 0..200 {
        xml.push_str("<a>");
    }
    for _ in 0..200 {
        xml.push_str("</a>");
    }
    xml.push_str("</Types>");
    let e = opc::parse_content_types(xml.as_bytes(), Limits::STRICT).unwrap_err();
    assert!(
        matches!(
            e.class(),
            ErrorClass::InvalidXmlStructure | ErrorClass::ResourceLimit
        ),
        "depth bomb gave {e}"
    );
    assert_ne!(e.class(), ErrorClass::InternalInvariant);
}

#[test]
fn malformed_xml_is_typed() {
    let e = opc::parse_content_types(b"<Types><Default></Types>", Limits::STRICT).unwrap_err();
    assert_ne!(e.class(), ErrorClass::InternalInvariant);
}

// ---------------------------------------------------------------------------
// Relationship traps (research I §3): external is inert, traversal is rejected.
// ---------------------------------------------------------------------------

#[test]
fn external_relationship_is_inert_and_traversal_target_is_rejected() {
    let external = b"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"r1\" Type=\"urn:x\" Target=\"https://evil.example/x\" TargetMode=\"External\"/></Relationships>";
    let rels =
        opc::parse_relationships(external, "/", Limits::STRICT).expect("parse external rels");
    assert_eq!(rels.len(), 1);
    assert!(rels[0].is_external(), "external rel must be inert");
    assert!(rels[0].resolved.is_none());

    // A traversal target is rejected by the part-name grammar.
    assert_eq!(
        class(opc::resolve_part_target(
            "/word/",
            "../../etc/passwd",
            Limits::STRICT
        )),
        Some(ErrorClass::InvalidPackageStructure)
    );
    // ... absolute URLs are classified as external (inert) by `is_absolute_uri`
    // and are never resolved as an internal package part.
    assert!(opc::is_absolute_uri("https://evil.example/x"));
    assert!(!opc::is_absolute_uri("word/document.xml"));
}

// ---------------------------------------------------------------------------
// EPUB reading path: DOCTYPE in XHTML is refused, script is inert data.
// ---------------------------------------------------------------------------

#[test]
fn xhtml_doctype_is_refused() {
    let xhtml = b"<?xml version=\"1.0\"?><!DOCTYPE html [<!ENTITY e \"x\">]>\
<html xmlns=\"http://www.w3.org/1999/xhtml\"><body><p>&e;</p></body></html>";
    assert_eq!(
        class(epub::content::parse_content(xhtml, "/", Limits::STRICT)),
        Some(ErrorClass::InvalidXmlStructure)
    );
}

#[test]
fn scripted_xhtml_is_parsed_as_inert_data() {
    let xhtml = b"<html xmlns=\"http://www.w3.org/1999/xhtml\"><body>\
<script>fetch(\"http://evil.example/\")</script><p>text</p></body></html>";
    let model = epub::content::parse_content(xhtml, "/", Limits::STRICT).expect("parse");
    assert!(
        model.scripted,
        "script presence must be recorded, not executed"
    );
    assert!(model.text().contains("text"));
    assert!(
        !model.text().contains("fetch"),
        "script body must never contribute reading text"
    );
}

#[test]
fn scripted_epub_fixture_declines_semantics_but_preserves_bytes() {
    // Parse the committed scripted chapter bytes through the content parser.
    let path = fixture_dir().join("epub_scripted.epub");
    let bytes = std::fs::read(path).expect("fixture");
    // The opaque floor is the invariant that always holds.
    let (descriptor, _) = vole_document::encode::encode(&bytes, Limits::DEFAULT).unwrap();
    let (out, _) =
        vole_document::materialize::decode_to_bytes(&descriptor, Limits::DEFAULT).unwrap();
    assert_eq!(out, bytes);
}