#![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")
}
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
}
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())
}
#[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"
),
}
}
}
}
#[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),
);
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"
);
}
}
#[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");
}
}
#[test]
fn structural_zip_faults_are_rejected_typed() {
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",
];
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"),
}
}
}
#[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;
}
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)
);
}
let cls = class(build_opc_model(&bytes, Limits::STRICT));
assert_eq!(
cls,
Some(ErrorClass::InvalidPackageStructure),
"[{name}] expected an InvalidPackageStructure rejection"
);
}
}
#[test]
fn package_identity_and_xml_faults_are_typed() {
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;
}
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"),
}
}
}
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() {
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);
}
#[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());
assert_eq!(
class(opc::resolve_part_target(
"/word/",
"../../etc/passwd",
Limits::STRICT
)),
Some(ErrorClass::InvalidPackageStructure)
);
assert!(opc::is_absolute_uri("https://evil.example/x"));
assert!(!opc::is_absolute_uri("word/document.xml"));
}
#[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() {
let path = fixture_dir().join("epub_scripted.epub");
let bytes = std::fs::read(path).expect("fixture");
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);
}