diff --git a/Cargo.lock b/Cargo.lock index 8397448..73bd711 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1686,6 +1686,7 @@ dependencies = [ "notify-debouncer-mini", "petgraph", "rayon", + "roxmltree", "serde", "serde_json", "serde_yaml_ng", @@ -1708,6 +1709,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", + "zip", ] [[package]] @@ -1733,6 +1735,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustix" version = "1.1.4" diff --git a/README.md b/README.md index df17199..3d843d8 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,10 @@ When `input_format` is omitted, Renderflow auto-detects the format from the file These document-source outputs are reflowable. The exact [fixed-layout EPUB route](docs/user-guide/fixed-layout-epub.md) uses an explicit, -ordered PNG or JPEG collection with its own policy and publication metadata. +ordered PNG or JPEG collection with its own bounded policy and publication +metadata. `renderflow ebook inspect` checks the package structure and reports +typed evidence; optional local EPUBCheck v5 supplies separate conformance +evidence when available. SVG input and fixed-layout KEPUB are unsupported. Not every input → output combination is supported. For example, `epub` and `latex` inputs cannot currently be converted to `docx`. Renderflow reports a clear error when an unsupported combination is specified. diff --git a/ROADMAP.md b/ROADMAP.md index 0a91fe8..7bd8863 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ schema: aether.architecture-document/v1 id: renderflow-roadmap title: Renderflow Roadmap kind: architecture-document -version: 0.1.5 +version: 0.1.6 status: draft owners: - egohygiene @@ -26,6 +26,30 @@ supersedes: [] # Renderflow Roadmap +## 2026-09-28 fixed-layout EPUB validation review handoff + +[#417](https://github.com/egohygiene/renderflow/issues/417) merged in +[PR #435](https://github.com/egohygiene/renderflow/pull/435) at +`eaf3b98d4236a74ba30b6e40456cbf9757675a76`. Its in-process packager +builds deterministic, bounded fixed-layout EPUB from an explicitly ordered +homogeneous PNG or JPEG collection. SVG and fixed-layout KEPUB remain +unsupported. The #418 validation suite uses synthetic artwork only. + +[#418](https://github.com/egohygiene/renderflow/issues/418) is the current +review checkpoint: inspect the generated package independently, distinguish +native structural results from optional EPUBCheck v5 results and unavailable +providers, exercise positive and adversarial synthetic fixtures, and align the +capability surfaces with that exact route. Missing or incomplete accessibility +evidence cannot be promoted to a claim of complete accessibility. A generated +EPUB still needs its selected reading-system and distribution-channel review. + +After #418 review and merge, reconcile and close parent +[#414](https://github.com/egohygiene/renderflow/issues/414) with commit and +workflow evidence. [#419](https://github.com/egohygiene/renderflow/issues/419) +then owns the immutable integration-candidate release for +[Flow #52](https://github.com/egohygiene/flow/issues/52). The release is a +separate checkpoint; this branch does not publish or approve it. + ## 2026-09-28 fixed-layout EPUB implementation handoff [#417](https://github.com/egohygiene/renderflow/issues/417) adds the exact diff --git a/crates/renderflow-cli/Cargo.toml b/crates/renderflow-cli/Cargo.toml index 7776957..e16c221 100644 --- a/crates/renderflow-cli/Cargo.toml +++ b/crates/renderflow-cli/Cargo.toml @@ -23,6 +23,7 @@ renderflow = { path = "../renderflow-core", package = "renderflow" } tempfile = "3" serde_json = "1" sha2 = "0.10" +zip = { version = "2.4", default-features = false, features = ["deflate"] } [[test]] name = "cli_tests" diff --git a/crates/renderflow-core/Cargo.toml b/crates/renderflow-core/Cargo.toml index b21daa2..0f0945e 100644 --- a/crates/renderflow-core/Cargo.toml +++ b/crates/renderflow-core/Cargo.toml @@ -59,6 +59,7 @@ petgraph = "0.8" tempfile = "3" zip = { version = "2.4", default-features = false, features = ["deflate"] } +roxmltree = "=0.20.0" ureq = { version = "2", features = ["json"] } diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 80af68c..6d39904 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -15,7 +15,7 @@ tools: fidelity: lossless support_tier: experimental license_notes: "In-process Renderflow implementation; see the Renderflow repository license." - distribution_notes: "Exact bounded ordered-page PNG/JPEG route only; SVG, reflowable EPUB, fixed-layout KEPUB, retailer acceptance, and independent EPUB 3.3 conformance are separate contracts." + distribution_notes: "Exact bounded ordered-page PNG/JPEG route only; SVG, reflowable EPUB, fixed-layout KEPUB, retailer acceptance, and optional EPUBCheck conformance are separate contracts." - id: tool.lulu-rules name: Pinned Lulu publication rule pack diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 2ae8165..5ac3452 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -288,7 +288,15 @@ pub fn run_cli(cli: Cli) -> Result<()> { input, format, epubcheck, - } => commands::ebook::run_inspect(&input, &format, epubcheck)?, + fixed_layout, + run_manifest, + } => commands::ebook::run_inspect( + &input, + &format, + epubcheck, + fixed_layout, + run_manifest.as_deref(), + )?, EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?, }, Some(Commands::Publication { subcommand }) => match subcommand { diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index d9be103..9e5b1ca 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -525,6 +525,12 @@ pub enum EbookCommands { /// Run the optional local EPUBCheck provider and include its report. #[arg(long)] epubcheck: bool, + /// Require exact native validation of the ordered PNG/JPEG fixed-layout route. + #[arg(long)] + fixed_layout: bool, + /// Bind the inspected EPUB to an explicit completed run manifest. + #[arg(long, value_name = "FILE")] + run_manifest: Option, }, /// Print the honest built-in EPUB/KEPUB capability contract. Capabilities { diff --git a/crates/renderflow-core/src/commands/ebook.rs b/crates/renderflow-core/src/commands/ebook.rs index 4a89dee..2e6d042 100644 --- a/crates/renderflow-core/src/commands/ebook.rs +++ b/crates/renderflow-core/src/commands/ebook.rs @@ -2,13 +2,53 @@ use std::path::Path; use anyhow::Result; -use crate::ebook::{inspect_ebook, EbookCapabilityContract}; +use crate::ebook::{ + inspect_ebook, inspect_ebook_with_manifest, EbookCapabilityContract, EbookDiagnostic, + EbookDiagnosticSeverity, FixedLayoutStatus, +}; -pub fn run_inspect(input: &str, format: &str, epubcheck: bool) -> Result<()> { - let inspection = inspect_ebook(Path::new(input), epubcheck)?; +pub fn run_inspect( + input: &str, + format: &str, + epubcheck: bool, + fixed_layout: bool, + run_manifest: Option<&str>, +) -> Result<()> { + if (fixed_layout || run_manifest.is_some()) + && std::fs::metadata(input)?.len() > 512 * 1024 * 1024 + 1024 * 1024 + { + anyhow::bail!( + "ebook.fixed_layout.bounds: EPUB file exceeds the exact route inspection bound" + ); + } + let mut inspection = match run_manifest { + Some(manifest) => { + inspect_ebook_with_manifest(Path::new(input), epubcheck, Path::new(manifest))? + } + None => inspect_ebook(Path::new(input), epubcheck)?, + }; + if fixed_layout + && !matches!( + inspection.fixed_layout.as_ref().map(|item| item.status), + Some(FixedLayoutStatus::Validated) + ) + { + if !matches!( + inspection.fixed_layout.as_ref().map(|item| item.status), + Some(FixedLayoutStatus::Invalid) + ) { + inspection.diagnostics.push(EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: "ebook.fixed_layout.unsupported".to_string(), + message: "EPUB does not satisfy the exact ordered PNG/JPEG fixed-layout route" + .to_string(), + }); + } + inspection.valid = false; + } emit(&inspection, format)?; if !inspection.valid - || inspection.epubcheck.as_ref().and_then(|item| item.passed) == Some(false) + || (epubcheck && inspection.epubcheck.as_ref().and_then(|item| item.passed) != Some(true)) { anyhow::bail!("e-book validation failed; see emitted evidence"); } diff --git a/crates/renderflow-core/src/ebook.rs b/crates/renderflow-core/src/ebook.rs index a99ac3c..7474227 100644 --- a/crates/renderflow-core/src/ebook.rs +++ b/crates/renderflow-core/src/ebook.rs @@ -11,12 +11,21 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use zip::ZipArchive; +use crate::evidence::{ + ArtifactRole, RunManifest, RunState, ValidationState, ARTIFACT_MANIFEST_SCHEMA_V1, + RUN_MANIFEST_SCHEMA_V1, +}; +use crate::fixed_layout_epub_validate::inspect_fixed_layout; +pub use crate::fixed_layout_epub_validate::{ + FixedLayoutEvidence, FixedLayoutPageEvidence, FixedLayoutStatus, +}; use crate::process::{ ProcessExecutor, ProcessNetworkPolicy, ProcessRequest, ToolProbeStatus, DEFAULT_CAPTURE_LIMIT_BYTES, }; pub const EBOOK_EVIDENCE_SCHEMA_V1: &str = "renderflow.ebook-evidence/v1"; +pub const EBOOK_CAPABILITIES_SCHEMA_V1: &str = "renderflow.ebook-capabilities/v1"; const MAX_INSPECTION_MEMBER_BYTES: u64 = 8 * 1024 * 1024; const MAX_TOTAL_XHTML_INSPECTION_BYTES: usize = 64 * 1024 * 1024; @@ -78,6 +87,28 @@ pub struct EpubCheckEvidence { pub duration_ms: Option, pub report: Option, pub diagnostic: Option, + /// These fields are optional so historical v1 inspection records remain readable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub executable: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub arguments: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub report_sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EpubCheckStatus { + Passed, + Invalid, + Unavailable, + ProviderError, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -100,6 +131,27 @@ pub struct EbookInspection { pub diagnostics: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub epubcheck: Option, + /// Independent inspection of the exact ordered PNG/JPEG package route. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed_layout: Option, + /// Optional binding to a canonical run manifest, never inferred from the ZIP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EbookProvenanceStatus { + Verified, + Stale, + Corrupt, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EbookProvenanceEvidence { + pub status: EbookProvenanceStatus, + pub run_id: Option, + pub diagnostic: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -113,20 +165,52 @@ pub struct EbookCapabilityContract { pub page_list_evidence: bool, pub accessibility_evidence: bool, pub retailer_acceptance_requires_provider_profile: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed_layout_epub_route: Option, +} + +/// The exact supported fixed-layout generation route, without implying generic +/// EPUB or fixed-layout KEPUB capability for arbitrary source collections. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FixedLayoutEpubRouteCapability { + pub capability: String, + pub provider_id: String, + pub source_formats: Vec, + pub target_format: String, + pub ordered_collection_required: bool, + pub homogeneous_local_sources_required: bool, + pub explicit_execution_policy_required: bool, + pub page_progression_directions: Vec, + pub spread_policies: Vec, + pub native_validation: bool, + pub optional_epubcheck_v5: bool, } impl EbookCapabilityContract { pub fn builtin() -> Self { Self { - schema: EBOOK_EVIDENCE_SCHEMA_V1.to_string(), + schema: EBOOK_CAPABILITIES_SCHEMA_V1.to_string(), epub_reflow_generation: true, - epub_fixed_layout_generation: false, + epub_fixed_layout_generation: true, kepub_reflow_generation: true, kepub_fixed_layout_generation: false, structural_inspection: true, page_list_evidence: true, accessibility_evidence: true, retailer_acceptance_requires_provider_profile: true, + fixed_layout_epub_route: Some(FixedLayoutEpubRouteCapability { + capability: crate::fixed_layout_epub::FIXED_EPUB_CAPABILITY.to_string(), + provider_id: crate::fixed_layout_epub::FIXED_EPUB_PROVIDER.to_string(), + source_formats: vec!["png".to_string(), "jpeg".to_string()], + target_format: "epub".to_string(), + ordered_collection_required: true, + homogeneous_local_sources_required: true, + explicit_execution_policy_required: true, + page_progression_directions: vec!["ltr".to_string(), "rtl".to_string()], + spread_policies: vec!["none".to_string()], + native_validation: true, + optional_epubcheck_v5: true, + }), } } } @@ -135,8 +219,59 @@ pub fn inspect_ebook(path: &Path, run_epubcheck: bool) -> Result archive, + Err(error) => { + let path_lower = path.to_string_lossy().to_ascii_lowercase(); + let variant = if path_lower.ends_with(".kepub") || path_lower.ends_with(".kepub.epub") { + EbookVariant::Kepub + } else { + EbookVariant::Epub + }; + return Ok(EbookInspection { + schema: EBOOK_EVIDENCE_SCHEMA_V1.to_string(), + source: path.display().to_string(), + source_sha256: digest, + variant, + valid: false, + epub_version: None, + package_document: None, + layout: EbookLayout::Unknown, + xhtml_documents: 0, + spine_items: 0, + navigation: false, + page_list: false, + metadata: EbookMetadataEvidence { + title: false, + creator: false, + language: false, + identifier: false, + rights: false, + }, + accessibility: EbookAccessibilityEvidence { + access_modes: 0, + accessibility_features: 0, + accessibility_hazards: 0, + accessibility_summary: false, + conforms_to: false, + }, + retailer_acceptance: "requires_provider_profile".to_string(), + diagnostics: vec![EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: "ebook.container.invalid".to_string(), + message: format!("EPUB ZIP container is unreadable: {error}"), + }], + epubcheck: run_epubcheck + .then(|| run_epubcheck_provider(path)) + .transpose()?, + fixed_layout: Some(FixedLayoutEvidence { + status: FixedLayoutStatus::Invalid, + pages: Vec::new(), + }), + provenance: None, + }); + } + }; let mimetype_envelope = archive.by_index(0).ok().is_some_and(|entry| { entry.name() == "mimetype" && entry.compression() == zip::CompressionMethod::Stored }); @@ -321,12 +456,35 @@ pub fn inspect_ebook(path: &Path, run_epubcheck: bool) -> Result Result Result { + let mut inspection = inspect_ebook(path, run_epubcheck)?; + let binding = verify_run_manifest(path, manifest_path, &inspection); + let (status, run_id, diagnostic) = match binding { + Ok(run_id) => (EbookProvenanceStatus::Verified, Some(run_id), None), + Err((status, message)) => { + inspection.valid = false; + inspection.diagnostics.push(EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: match status { + EbookProvenanceStatus::Corrupt => "ebook.provenance.corrupt", + EbookProvenanceStatus::Stale => "ebook.provenance.stale", + EbookProvenanceStatus::Verified => unreachable!(), + } + .to_string(), + message: message.clone(), + }); + (status, None, Some(message)) + } + }; + inspection.provenance = Some(EbookProvenanceEvidence { + status, + run_id, + diagnostic, + }); + if sha256_file(path)? != inspection.source_sha256 { + inspection.valid = false; + inspection.diagnostics.push(EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: "ebook.provenance.stale".to_string(), + message: "EPUB bytes changed during run-manifest binding".to_string(), + }); + inspection.provenance = Some(EbookProvenanceEvidence { + status: EbookProvenanceStatus::Stale, + run_id: None, + diagnostic: Some("EPUB bytes changed during run-manifest binding".to_string()), + }); + } + Ok(inspection) +} + +fn verify_run_manifest( + path: &Path, + manifest_path: &Path, + inspection: &EbookInspection, +) -> std::result::Result { + let corrupt = |message: String| (EbookProvenanceStatus::Corrupt, message); + let stale = |message: String| (EbookProvenanceStatus::Stale, message); + let size = std::fs::metadata(manifest_path) + .map_err(|error| corrupt(format!("run manifest is unavailable: {error}")))? + .len(); + if size == 0 || size > 32 * 1024 * 1024 { + return Err(corrupt( + "run manifest is empty or exceeds 32 MiB".to_string(), + )); + } + let manifest_bytes = std::fs::read(manifest_path) + .map_err(|error| corrupt(format!("run manifest cannot be read: {error}")))?; + let manifest: RunManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| corrupt(format!("run manifest JSON/contract is corrupt: {error}")))?; + if manifest.schema_version != RUN_MANIFEST_SCHEMA_V1 + || manifest.artifact_manifest.schema_version != ARTIFACT_MANIFEST_SCHEMA_V1 + || manifest.run_id != manifest.artifact_manifest.run_id + || manifest.state != RunState::Complete + { + return Err(corrupt( + "run manifest schema, identity, or completed state is inconsistent".to_string(), + )); + } + let terminals = manifest + .artifact_manifest + .artifacts + .iter() + .filter(|artifact| artifact.lifecycle == ArtifactRole::Terminal && artifact.role == "ebook") + .collect::>(); + if terminals.len() != 1 { + return Err(corrupt( + "run manifest must identify exactly one terminal ebook".to_string(), + )); + } + let terminal = terminals[0]; + if terminal.format != "epub" + || terminal.media_type != "application/epub+zip" + || terminal.producer.capability.as_deref() + != Some(crate::fixed_layout_epub::FIXED_EPUB_CAPABILITY) + || terminal.producer.provider.as_deref() + != Some(crate::fixed_layout_epub::FIXED_EPUB_PROVIDER) + || !matches!( + terminal.validation, + ValidationState::Valid | ValidationState::ValidWithWarnings + ) + { + return Err(corrupt( + "run manifest terminal does not describe the exact validated EPUB route".to_string(), + )); + } + let locator = terminal + .locator + .strip_prefix("bundle:") + .ok_or_else(|| corrupt("terminal EPUB has no bundle locator".to_string()))?; + if locator.is_empty() + || !Path::new(locator) + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))) + { + return Err(corrupt("terminal EPUB locator is unsafe".to_string())); + } + let manifest_root = manifest_path + .parent() + .ok_or_else(|| corrupt("run manifest has no output directory".to_string()))? + .canonicalize() + .map_err(|error| { + corrupt(format!( + "run manifest output directory cannot be resolved: {error}" + )) + })?; + let declared_root = Path::new(&manifest.artifact_manifest.output_dir); + if (declared_root.is_absolute() + && declared_root.canonicalize().map_err(|error| { + stale(format!( + "declared output directory cannot be resolved: {error}" + )) + })? != manifest_root) + || (declared_root.is_relative() + && declared_root != Path::new(".") + && !manifest_root.ends_with(declared_root)) + { + return Err(stale( + "run manifest output directory differs from its location".to_string(), + )); + } + let expected_path = manifest_root.join(locator); + let actual = path + .canonicalize() + .map_err(|error| stale(format!("EPUB path cannot be resolved: {error}")))?; + let expected = expected_path + .canonicalize() + .map_err(|error| stale(format!("manifest EPUB path cannot be resolved: {error}")))?; + if actual != expected + || terminal.digest.algorithm != "sha256" + || terminal.digest.value != inspection.source_sha256 + || terminal.size_bytes + != std::fs::metadata(path) + .map_err(|error| stale(error.to_string()))? + .len() + { + return Err(stale( + "EPUB path, size, or SHA-256 differs from the run manifest".to_string(), + )); + } + let pages = inspection + .fixed_layout + .as_ref() + .filter(|evidence| evidence.status == FixedLayoutStatus::Validated) + .map(|evidence| &evidence.pages) + .ok_or_else(|| stale("native fixed-layout validation did not pass".to_string()))?; + let sources = manifest + .artifact_manifest + .artifacts + .iter() + .filter(|artifact| artifact.lifecycle == ArtifactRole::Source) + .collect::>(); + let declared = terminal + .metadata + .get("renderflow.fixed_epub.ordered_pages") + .and_then(Value::as_array) + .ok_or_else(|| corrupt("ordered page provenance is missing".to_string()))?; + if pages.len() != sources.len() + || pages.len() != declared.len() + || terminal.sources + != sources + .iter() + .map(|source| source.artifact_id.clone()) + .collect::>() + { + return Err(stale( + "ordered source lineage differs from validated pages".to_string(), + )); + } + for (index, ((page, source), declared)) in pages.iter().zip(&sources).zip(declared).enumerate() + { + if declared.get("index").and_then(Value::as_u64) != Some(index as u64) + || declared.get("source_id").and_then(Value::as_str) != Some(source.role.as_str()) + || declared.get("sha256").and_then(Value::as_str) != Some(page.image_sha256.as_str()) + || source.digest.algorithm != "sha256" + || source.digest.value != page.image_sha256 + { + return Err(stale(format!( + "page {} differs from ordered source provenance", + index + 1 + ))); + } + } + Ok(manifest.run_id) +} + pub fn run_epubcheck_provider(path: &Path) -> Result { + let executable = epubcheck_executable().unwrap_or_else(|| "epubcheck".to_string()); + run_epubcheck_with_executable(path, &executable) +} + +fn epubcheck_executable() -> Option { + let path = std::env::var_os("PATH")?; + for directory in std::env::split_paths(&path) { + for name in if cfg!(windows) { + &["epubcheck.exe", "epubcheck.cmd", "epubcheck.bat"][..] + } else { + &["epubcheck"][..] + } { + let candidate = directory.join(name); + if candidate.is_file() { + // Preserve the invoked symlink path: some launcher scripts use + // their own directory to locate the EPUBCheck JAR. + let absolute = if candidate.is_absolute() { + candidate + } else { + std::env::current_dir().ok()?.join(candidate) + }; + return Some(absolute.display().to_string()); + } + } + } + None +} + +fn epubcheck_major(version: &str) -> Option<(u32, String)> { + let lower = version.to_ascii_lowercase(); + let after_name = lower + .find("epubcheck") + .map_or(lower.as_str(), |index| &lower[index + "epubcheck".len()..]); + let start = after_name.find(|character: char| character.is_ascii_digit())?; + let token: String = after_name[start..] + .chars() + .take_while(|character| character.is_ascii_digit() || *character == '.') + .collect(); + let major = token.split('.').next()?.parse().ok()?; + Some((major, token.trim_end_matches('.').to_string())) +} + +fn run_epubcheck_with_executable(path: &Path, executable: &str) -> Result { + let input_sha256 = sha256_file(path)?; let executor = ProcessExecutor::new(); - let probe = executor.probe_version("epubcheck"); - if probe.status != ToolProbeStatus::Available { + let probe = executor.probe_version(executable); + let version = probe.version_line.clone(); + if probe.status != ToolProbeStatus::Available + || !version.as_deref().is_some_and(|line| { + line.to_ascii_lowercase().contains("epubcheck") + && epubcheck_major(line).is_some_and(|(major, _)| major == 5) + }) + { + let diagnostic = if probe.status == ToolProbeStatus::Available { + Some(format!( + "EPUBCheck v5 is required; version probe returned '{}'", + version.as_deref().unwrap_or("no version") + )) + } else { + probe.diagnostic + }; return Ok(EpubCheckEvidence { provider_id: "tool.epubcheck".to_string(), available: false, - version: probe.version_line, + version, passed: None, duration_ms: Some(probe.duration_ms), report: None, - diagnostic: probe.diagnostic, + diagnostic, + status: Some( + if probe.status == ToolProbeStatus::Missing + || probe.status == ToolProbeStatus::Available + { + EpubCheckStatus::Unavailable + } else { + EpubCheckStatus::ProviderError + }, + ), + executable: Some(executable.to_string()), + arguments: vec![], + input_sha256: Some(input_sha256), + report_sha256: None, + exit_code: None, }); } let directory = tempfile::tempdir().context("failed to create EPUBCheck evidence directory")?; let report_path = directory.path().join("epubcheck.json"); - let result = executor.execute( - ProcessRequest::direct("epubcheck") - .arg(path.to_string_lossy().into_owned()) - .arg("--json") - .arg(report_path.to_string_lossy().into_owned()) - .timeout(Duration::from_secs(5 * 60)) - .capture_limit(DEFAULT_CAPTURE_LIMIT_BYTES) - .network_policy(ProcessNetworkPolicy::Deny), - )?; - let report = std::fs::read(&report_path) + let arguments = vec![ + path.to_string_lossy().into_owned(), + "--json".to_string(), + report_path.to_string_lossy().into_owned(), + ]; + let request = ProcessRequest::direct(executable) + .args(arguments.iter().cloned()) + .timeout(Duration::from_secs(5 * 60)) + .capture_limit(DEFAULT_CAPTURE_LIMIT_BYTES) + .network_policy(ProcessNetworkPolicy::Deny); + let result = match executor.execute(request) { + Ok(result) => result, + Err(error) => { + let missing = matches!( + &error, + crate::process::ProcessError::MissingExecutable { .. } + ); + return Ok(EpubCheckEvidence { + provider_id: "tool.epubcheck".to_string(), + available: false, + version, + passed: None, + duration_ms: None, + report: None, + diagnostic: Some(error.to_string()), + status: Some(if missing { + EpubCheckStatus::Unavailable + } else { + EpubCheckStatus::ProviderError + }), + executable: Some(executable.to_string()), + arguments, + input_sha256: Some(input_sha256), + report_sha256: None, + exit_code: None, + }); + } + }; + let exit_code = match result.termination() { + crate::process::ProcessTermination::Exited { code } => Some(code), + _ => None, + }; + let raw_report = std::fs::metadata(&report_path) .ok() - .and_then(|bytes| serde_json::from_slice(&bytes).ok()); - let diagnostic = (!result.is_success()).then(|| { - let stderr = result.stderr().redacted_text().trim(); - if stderr.is_empty() { - "EPUBCheck reported conformance errors".to_string() - } else { - stderr.to_string() + .filter(|metadata| metadata.is_file() && metadata.len() <= 8 * 1024 * 1024) + .and_then(|_| std::fs::read(&report_path).ok()); + let report_sha256 = raw_report + .as_deref() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); + let report: Option = raw_report + .as_deref() + .and_then(|bytes| serde_json::from_slice(bytes).ok()); + let report_error = match report.as_ref() { + Some(report) => { + epubcheck_report_error(report, version.as_deref().unwrap_or_default(), path) } - }); + None => Some("EPUBCheck JSON report is missing, oversized, or malformed".to_string()), + }; + let source_changed = sha256_file(path).ok().as_deref() != Some(input_sha256.as_str()); + let (status, passed, diagnostic) = if source_changed { + ( + EpubCheckStatus::ProviderError, + None, + Some("EPUB source changed while EPUBCheck was running".to_string()), + ) + } else if !matches!( + result.termination(), + crate::process::ProcessTermination::Exited { .. } + ) { + ( + EpubCheckStatus::ProviderError, + None, + Some(result.failure_message()), + ) + } else if let Some(error) = report_error { + (EpubCheckStatus::ProviderError, None, Some(error)) + } else if result.is_success() && report.as_ref().is_some_and(epubcheck_report_has_errors) { + ( + EpubCheckStatus::ProviderError, + None, + Some("EPUBCheck exited successfully but its JSON report contains errors".to_string()), + ) + } else if result.is_success() { + (EpubCheckStatus::Passed, Some(true), None) + } else if exit_code.is_some() && report.as_ref().is_some_and(epubcheck_report_has_errors) { + ( + EpubCheckStatus::Invalid, + Some(false), + Some("EPUBCheck reported conformance errors".to_string()), + ) + } else { + ( + EpubCheckStatus::ProviderError, + None, + Some(result.failure_message()), + ) + }; Ok(EpubCheckEvidence { provider_id: "tool.epubcheck".to_string(), available: true, - version: probe.version_line, - passed: Some(result.is_success()), + version, + passed, duration_ms: Some(result.duration_ms()), report, diagnostic, + status: Some(status), + executable: Some(executable.to_string()), + arguments, + input_sha256: Some(input_sha256), + report_sha256, + exit_code, }) } +fn epubcheck_report_error(report: &Value, probe_version: &str, path: &Path) -> Option { + let Some(checker) = report.get("checker").and_then(Value::as_object) else { + return Some("EPUBCheck JSON report has no checker metadata".to_string()); + }; + let Some((_, probed)) = epubcheck_major(probe_version) else { + return Some("EPUBCheck version probe was not parseable".to_string()); + }; + let Some((_, reported)) = checker + .get("checkerVersion") + .and_then(Value::as_str) + .and_then(epubcheck_major) + else { + return Some("EPUBCheck JSON report has no checker version".to_string()); + }; + if probed != reported { + return Some("EPUBCheck report version differs from the executable probe".to_string()); + } + if checker.get("filename").and_then(Value::as_str) + != path.file_name().and_then(|name| name.to_str()) + { + return Some("EPUBCheck report filename differs from the checked input".to_string()); + } + for key in ["nFatal", "nError"] { + if checker.get(key).and_then(Value::as_u64).is_none() { + return Some(format!("EPUBCheck JSON report has no valid {key} count")); + } + } + if report + .get("publication") + .and_then(Value::as_object) + .is_none() + || report.get("items").and_then(Value::as_array).is_none() + || report.get("messages").and_then(Value::as_array).is_none() + { + return Some( + "EPUBCheck JSON report is missing publication, items, or messages".to_string(), + ); + } + None +} + +fn epubcheck_report_has_errors(report: &Value) -> bool { + let checker = &report["checker"]; + checker["nFatal"].as_u64().unwrap_or(0) > 0 + || checker["nError"].as_u64().unwrap_or(0) > 0 + || report["messages"].as_array().is_some_and(|messages| { + messages.iter().any(|message| { + message["severity"].as_str().is_some_and(|severity| { + matches!(severity.to_ascii_lowercase().as_str(), "fatal" | "error") + }) + }) + }) +} + +#[cfg(all(test, unix))] +#[path = "ebook_provider_tests.rs"] +mod epubcheck_provider_tests; + fn read_member( archive: &mut ZipArchive, name: &str, diff --git a/crates/renderflow-core/src/ebook_provider_tests.rs b/crates/renderflow-core/src/ebook_provider_tests.rs new file mode 100644 index 0000000..b0d9aa6 --- /dev/null +++ b/crates/renderflow-core/src/ebook_provider_tests.rs @@ -0,0 +1,141 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use serde_json::{json, Value}; + +use super::{run_epubcheck_with_executable, EpubCheckStatus}; + +fn fake_checker( + root: &Path, + input: &Path, + version: &str, + report: Option, + exit_code: i32, +) -> PathBuf { + let script = root.join("epubcheck"); + let report_copy = report.map(|value| { + let path = root.join("fake-report.json"); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + path + }); + let command = report_copy.map_or_else( + || "".to_string(), + |report| format!("cp \"{}\" \"$3\"\n", report.display()), + ); + let body = format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf \"EPUBCheck v{version}\\n\"; exit 0; fi\n{command}exit {exit_code}\n" + ); + std::fs::write(&script, body).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(input.exists()); + script +} + +fn report(input: &Path, errors: u64) -> Value { + json!({ + "checker": { + "checkerVersion": "5.3.0", + "filename": input.file_name().unwrap().to_str().unwrap(), + "nFatal": 0, + "nError": errors, + "nWarning": 0 + }, + "publication": {}, + "items": [], + "messages": if errors == 0 { vec![] } else { vec![json!({"severity": "error"})] } + }) +} + +#[test] +fn epubcheck_v5_report_binds_executable_input_arguments_and_exit() { + let root = tempfile::tempdir().unwrap(); + let input = root.path().join("synthetic.epub"); + std::fs::write(&input, b"synthetic provider fixture").unwrap(); + let script = fake_checker(root.path(), &input, "5.3.0", Some(report(&input, 0)), 0); + let evidence = run_epubcheck_with_executable(&input, script.to_str().unwrap()).unwrap(); + assert_eq!(evidence.status, Some(EpubCheckStatus::Passed)); + assert_eq!(evidence.passed, Some(true)); + assert_eq!(evidence.exit_code, Some(0)); + assert_eq!(evidence.executable.as_deref(), script.to_str()); + assert_eq!(evidence.arguments.len(), 3); + assert_eq!(evidence.arguments[0], input.to_str().unwrap()); + assert_eq!(evidence.arguments[1], "--json"); + assert_eq!(evidence.input_sha256.as_deref().map(str::len), Some(64)); + assert_eq!(evidence.report_sha256.as_deref().map(str::len), Some(64)); + assert!(evidence.report.is_some()); +} + +#[test] +fn epubcheck_conformance_failure_is_distinct_from_provider_failure() { + let root = tempfile::tempdir().unwrap(); + let input = root.path().join("synthetic.epub"); + std::fs::write(&input, b"synthetic provider fixture").unwrap(); + let script = fake_checker(root.path(), &input, "5.3.0", Some(report(&input, 1)), 1); + let evidence = run_epubcheck_with_executable(&input, script.to_str().unwrap()).unwrap(); + assert_eq!(evidence.status, Some(EpubCheckStatus::Invalid)); + assert_eq!(evidence.passed, Some(false)); + assert_eq!(evidence.exit_code, Some(1)); +} + +#[test] +fn epubcheck_missing_malformed_and_mismatched_reports_cannot_pass() { + for case in ["missing", "malformed", "wrong_version", "wrong_filename"] { + let root = tempfile::tempdir().unwrap(); + let input = root.path().join("synthetic.epub"); + std::fs::write(&input, b"synthetic provider fixture").unwrap(); + let generated = match case { + "missing" => None, + "malformed" => Some(json!({"checker": {}})), + "wrong_version" => { + let mut value = report(&input, 0); + value["checker"]["checkerVersion"] = json!("5.2.0"); + Some(value) + } + "wrong_filename" => { + let mut value = report(&input, 0); + value["checker"]["filename"] = json!("other.epub"); + Some(value) + } + _ => unreachable!(), + }; + let script = fake_checker(root.path(), &input, "5.3.0", generated, 0); + let evidence = run_epubcheck_with_executable(&input, script.to_str().unwrap()).unwrap(); + assert_eq!( + evidence.status, + Some(EpubCheckStatus::ProviderError), + "{case}" + ); + assert_eq!(evidence.passed, None, "{case}"); + } +} + +#[test] +fn epubcheck_incompatible_version_is_unavailable() { + let root = tempfile::tempdir().unwrap(); + let input = root.path().join("synthetic.epub"); + std::fs::write(&input, b"synthetic provider fixture").unwrap(); + let script = fake_checker(root.path(), &input, "4.2.6", Some(report(&input, 0)), 0); + let evidence = run_epubcheck_with_executable(&input, script.to_str().unwrap()).unwrap(); + assert_eq!(evidence.status, Some(EpubCheckStatus::Unavailable)); + assert!(!evidence.available); + assert_eq!(evidence.passed, None); + assert!(evidence.arguments.is_empty()); +} + +#[test] +fn missing_executable_and_failed_version_probe_have_distinct_states() { + let root = tempfile::tempdir().unwrap(); + let input = root.path().join("synthetic.epub"); + std::fs::write(&input, b"synthetic provider fixture").unwrap(); + let missing = root.path().join("not-installed"); + let absent = run_epubcheck_with_executable(&input, missing.to_str().unwrap()).unwrap(); + assert_eq!(absent.status, Some(EpubCheckStatus::Unavailable)); + assert_eq!(absent.passed, None); + + let script = root.path().join("broken-epubcheck"); + std::fs::write(&script, "#!/bin/sh\nexit 7\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let failed = run_epubcheck_with_executable(&input, script.to_str().unwrap()).unwrap(); + assert_eq!(failed.status, Some(EpubCheckStatus::ProviderError)); + assert_eq!(failed.passed, None); +} diff --git a/crates/renderflow-core/src/fixed_layout_epub.rs b/crates/renderflow-core/src/fixed_layout_epub.rs index df17f7b..8f7efdb 100644 --- a/crates/renderflow-core/src/fixed_layout_epub.rs +++ b/crates/renderflow-core/src/fixed_layout_epub.rs @@ -31,6 +31,9 @@ pub const FIXED_EPUB_PROVIDER: &str = "tool.renderflow-epub"; const MIMETYPE: &[u8] = b"application/epub+zip"; const CONTAINER: &str = "\n\n"; const CSS: &str = "@charset \"UTF-8\";\nhtml,body{width:100%;height:100%;margin:0;padding:0;}\nbody{overflow:hidden;}\nimg.page{display:block;width:100%;height:100%;object-fit:contain;}\n"; +pub(crate) fn generated_stylesheet() -> &'static str { + CSS +} const BUFFER_SIZE: usize = 64 * 1024; /// A machine-readable refusal that can be propagated into DAG step evidence. diff --git a/crates/renderflow-core/src/fixed_layout_epub_validate.rs b/crates/renderflow-core/src/fixed_layout_epub_validate.rs new file mode 100644 index 0000000..07d5bb0 --- /dev/null +++ b/crates/renderflow-core/src/fixed_layout_epub_validate.rs @@ -0,0 +1,974 @@ +//! Independent, bounded inspection for Renderflow's exact ordered-image EPUB route. +//! +//! This deliberately accepts only the narrow PNG/JPEG publication contract. +//! General EPUB 3.3 conformance remains the job of an independently identified +//! EPUBCheck provider; accepting an arbitrary ZIP or matching text fragments is +//! not evidence that a fixed-layout publication is safe or internally coherent. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use roxmltree::{Document, Node}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zip::{CompressionMethod, ZipArchive}; + +use crate::ebook::{EbookDiagnostic, EbookDiagnosticSeverity}; +use crate::graph::Format; +use crate::print_pdf_image::inspect_print_image; + +const CONTAINER_NS: &str = "urn:oasis:names:tc:opendocument:xmlns:container"; +const OPF_NS: &str = "http://www.idpf.org/2007/opf"; +const DC_NS: &str = "http://purl.org/dc/elements/1.1/"; +const XHTML_NS: &str = "http://www.w3.org/1999/xhtml"; +const EPUB_NS: &str = "http://www.idpf.org/2007/ops"; +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; +const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_XML_BYTES: u64 = 2 * 1024 * 1024; +const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; +const MAX_MEMBERS: usize = 2005; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FixedLayoutStatus { + Validated, + Invalid, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FixedLayoutPageEvidence { + pub page: String, + pub image: String, + pub image_sha256: String, + pub width_px: u32, + pub height_px: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FixedLayoutEvidence { + pub status: FixedLayoutStatus, + pub pages: Vec, +} + +#[derive(Debug)] +struct Refusal { + code: &'static str, + message: String, +} + +type Checked = std::result::Result; + +fn refuse(code: &'static str, message: impl Into) -> Checked { + Err(Refusal { + code, + message: message.into(), + }) +} + +fn required(condition: bool, code: &'static str, message: impl Into) -> Checked<()> { + if condition { + Ok(()) + } else { + refuse(code, message) + } +} + +pub(crate) fn inspect_fixed_layout( + archive: &mut ZipArchive, + path: &Path, +) -> (FixedLayoutEvidence, Vec) { + match validate(archive, path) { + Ok(pages) => ( + FixedLayoutEvidence { + status: FixedLayoutStatus::Validated, + pages, + }, + Vec::new(), + ), + Err(failure) => ( + FixedLayoutEvidence { + status: FixedLayoutStatus::Invalid, + pages: Vec::new(), + }, + vec![EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: failure.code.to_string(), + message: failure.message, + }], + ), + } +} + +fn validate( + archive: &mut ZipArchive, + path: &Path, +) -> Checked> { + validate_central_directory(path, archive.central_directory_start(), archive.len())?; + required( + (7..=MAX_MEMBERS).contains(&archive.len()), + "ebook.fixed_layout.member", + "fixed-layout package member count is outside the exact route bound", + )?; + let mut names = Vec::with_capacity(archive.len()); + let mut seen = HashSet::new(); + let mut total = 0_u64; + for index in 0..archive.len() { + let member = archive.by_index(index).map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("unreadable ZIP member {index}: {error}"), + })?; + let name = member.name().to_string(); + required( + safe_member_name(&name) && seen.insert(name.to_ascii_lowercase()), + "ebook.fixed_layout.member", + format!("unsafe, duplicate, or case-colliding ZIP member: {name}"), + )?; + required( + member.compression() == CompressionMethod::Stored + && member + .unix_mode() + .is_none_or(|mode| mode & 0o170000 != 0o120000), + "ebook.fixed_layout.member", + format!("unsupported compressed or symlink ZIP member: {name}"), + )?; + let timestamp = member.last_modified(); + required( + timestamp.is_some_and(|date| { + date.year() == 1980 + && date.month() == 1 + && date.day() == 1 + && date.hour() == 0 + && date.minute() == 0 + && date.second() == 0 + }), + "ebook.fixed_layout.member", + format!("non-deterministic ZIP member timestamp: {name}"), + )?; + total = total.checked_add(member.size()).ok_or_else(|| Refusal { + code: "ebook.fixed_layout.bounds", + message: "ZIP uncompressed size overflowed".to_string(), + })?; + required( + total <= MAX_ARCHIVE_BYTES, + "ebook.fixed_layout.bounds", + "fixed-layout package exceeds its uncompressed 512 MiB bound", + )?; + names.push(name); + } + + let mut mimetype = archive.by_index(0).map_err(|error| Refusal { + code: "ebook.fixed_layout.mimetype", + message: error.to_string(), + })?; + required( + mimetype.name() == "mimetype" + && mimetype.compression() == CompressionMethod::Stored + && mimetype.data_start() == 38 + && mimetype.extra_data().is_none_or(|extra| extra.is_empty()), + "ebook.fixed_layout.mimetype", + "mimetype must be the first, stored, extra-free ZIP local entry", + )?; + let mut mime = Vec::new(); + mimetype.read_to_end(&mut mime).map_err(|error| Refusal { + code: "ebook.fixed_layout.mimetype", + message: error.to_string(), + })?; + required( + mime == b"application/epub+zip", + "ebook.fixed_layout.mimetype", + "mimetype bytes must be exact, without BOM or padding", + )?; + drop(mimetype); + + let container_text = read_text(archive, "META-INF/container.xml", "ebook.fixed_layout.xml")?; + let container = parse_xml(&container_text)?; + let root = container.root_element(); + required( + element(root, CONTAINER_NS, "container"), + "ebook.fixed_layout.xml", + "container.xml has an unexpected root or namespace", + )?; + let rootfiles = one_child(root, CONTAINER_NS, "rootfiles", "ebook.fixed_layout.xml")?; + let rootfile = one_child( + rootfiles, + CONTAINER_NS, + "rootfile", + "ebook.fixed_layout.xml", + )?; + required( + rootfile.attribute("full-path") == Some("EPUB/book.opf") + && rootfile.attribute("media-type") == Some("application/oebps-package+xml"), + "ebook.fixed_layout.resource", + "container rootfile must resolve to the declared local OPF", + )?; + + let opf_text = read_text(archive, "EPUB/book.opf", "ebook.fixed_layout.xml")?; + let opf = parse_xml(&opf_text)?; + let package = opf.root_element(); + required( + element(package, OPF_NS, "package") && package.attribute("version") == Some("3.0"), + "ebook.fixed_layout.metadata", + "fixed-layout OPF must declare the EPUB 3.3 package version 3.0", + )?; + let metadata = one_child(package, OPF_NS, "metadata", "ebook.fixed_layout.metadata")?; + let manifest = one_child(package, OPF_NS, "manifest", "ebook.fixed_layout.resource")?; + let spine = one_child(package, OPF_NS, "spine", "ebook.fixed_layout.spine")?; + for name in ["title", "language", "identifier", "rights"] { + let node = one_child(metadata, DC_NS, name, "ebook.fixed_layout.metadata")?; + required( + !node.text().unwrap_or_default().trim().is_empty(), + "ebook.fixed_layout.metadata", + format!("dc:{name} is empty"), + )?; + if name == "identifier" { + required( + node.attribute("id") == package.attribute("unique-identifier"), + "ebook.fixed_layout.metadata", + "unique-identifier does not reference the publication identifier", + )?; + } + } + required( + metadata + .children() + .filter(|node| element(*node, DC_NS, "creator") || element(*node, DC_NS, "contributor")) + .any(|node| !node.text().unwrap_or_default().trim().is_empty()), + "ebook.fixed_layout.metadata", + "publication contributor metadata is absent", + )?; + require_meta( + metadata, + "dcterms:modified", + |value| { + value.len() == 20 && value.ends_with('Z') && value.as_bytes().get(10) == Some(&b'T') + }, + "ebook.fixed_layout.metadata", + )?; + require_meta( + metadata, + "rendition:layout", + |value| value == "pre-paginated", + "ebook.fixed_layout.metadata", + )?; + require_meta( + metadata, + "rendition:spread", + |value| value == "none", + "ebook.fixed_layout.metadata", + )?; + require_meta( + metadata, + "schema:accessibilitySummary", + |value| !value.trim().is_empty(), + "ebook.fixed_layout.accessibility", + )?; + require_meta( + metadata, + "schema:accessMode", + |value| value == "visual", + "ebook.fixed_layout.accessibility", + )?; + let hazards = metadata + .children() + .filter(|node| { + element(*node, OPF_NS, "meta") + && node.attribute("property") == Some("schema:accessibilityHazard") + }) + .collect::>(); + required( + !hazards.is_empty() + && hazards + .iter() + .all(|node| !node.text().unwrap_or_default().trim().is_empty()), + "ebook.fixed_layout.accessibility", + "nonempty accessibility hazard declarations are required", + )?; + let features = metadata + .children() + .filter(|node| { + element(*node, OPF_NS, "meta") + && node.attribute("property") == Some("schema:accessibilityFeature") + }) + .map(|node| node.text().unwrap_or_default().trim()) + .collect::>(); + required( + features.len() == 2 + && features.contains(&"tableOfContents") + && features.contains(&"pageNavigation"), + "ebook.fixed_layout.accessibility", + "declared table-of-contents and page-navigation features must match the package", + )?; + required( + matches!( + spine.attribute("page-progression-direction"), + Some("ltr" | "rtl") + ), + "ebook.fixed_layout.spine", + "page progression must be explicitly ltr or rtl", + )?; + + let mut ids = HashSet::new(); + let mut hrefs = HashSet::new(); + let mut items = HashMap::new(); + let mut nav_count = 0; + let mut cover_count = 0; + for item in manifest.children().filter(|node| node.is_element()) { + required( + element(item, OPF_NS, "item"), + "ebook.fixed_layout.resource", + "unexpected manifest element", + )?; + let id = item.attribute("id").unwrap_or_default(); + let href = item.attribute("href").unwrap_or_default(); + let path = resolve_href("EPUB/book.opf", href)?; + required( + !id.is_empty() && ids.insert(id.to_string()) && hrefs.insert(path.clone()), + "ebook.fixed_layout.resource", + "manifest IDs and hrefs must be nonempty and unique", + )?; + required( + seen.contains(&path.to_ascii_lowercase()), + "ebook.fixed_layout.resource", + format!("manifest href does not resolve to a ZIP member: {href}"), + )?; + let properties = item.attribute("properties").unwrap_or_default(); + nav_count += usize::from( + properties + .split_ascii_whitespace() + .any(|token| token == "nav"), + ); + cover_count += usize::from( + properties + .split_ascii_whitespace() + .any(|token| token == "cover-image"), + ); + items.insert( + id.to_string(), + ( + path, + item.attribute("media-type").unwrap_or_default().to_string(), + properties.to_string(), + ), + ); + } + required( + nav_count == 1, + "ebook.fixed_layout.navigation", + "exactly one nav manifest item is required", + )?; + required( + cover_count == 1, + "ebook.fixed_layout.cover", + "exactly one cover-image is required", + )?; + required( + items.get("nav").is_some_and(|item| { + item.0 == "EPUB/nav.xhtml" && item.1 == "application/xhtml+xml" && item.2 == "nav" + }), + "ebook.fixed_layout.navigation", + "navigation manifest relationship is missing or incorrect", + )?; + required( + items + .get("styles") + .is_some_and(|item| item.0 == "EPUB/styles.css" && item.1 == "text/css"), + "ebook.fixed_layout.resource", + "local stylesheet manifest relationship is missing", + )?; + let spine_ids = spine + .children() + .filter(|node| node.is_element()) + .map(|node| { + ( + element(node, OPF_NS, "itemref"), + node.attribute("idref").unwrap_or_default().to_string(), + ) + }) + .collect::>(); + required( + !spine_ids.is_empty() && spine_ids.len() <= 1000 && spine_ids.iter().all(|item| item.0), + "ebook.fixed_layout.spine", + "ordered spine must contain 1 to 1000 XHTML page references", + )?; + let count = spine_ids.len(); + required( + items.len() == 2 + 2 * count, + "ebook.fixed_layout.resource", + "manifest must contain exactly nav, stylesheet, and one XHTML/image pair per page", + )?; + let mut pages = Vec::with_capacity(count); + for (index, (_, idref)) in spine_ids.iter().enumerate() { + let number = index + 1; + let page_id = format!("page-{number:04}"); + let image_id = format!("image-{number:04}"); + let page_path = format!("EPUB/pages/page-{number:04}.xhtml"); + required( + idref == &page_id + && items + .get(&page_id) + .is_some_and(|item| item.0 == page_path && item.1 == "application/xhtml+xml"), + "ebook.fixed_layout.spine", + format!("spine item {number} does not resolve to its ordered XHTML page"), + )?; + let image_item = items.get(&image_id).ok_or_else(|| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("page {number} has no declared image"), + })?; + let format = match image_item.1.as_str() { + "image/png" if image_item.0 == format!("EPUB/images/page-{number:04}.png") => { + Format::Png + } + "image/jpeg" if image_item.0 == format!("EPUB/images/page-{number:04}.jpg") => { + Format::Jpeg + } + _ => { + return refuse( + "ebook.fixed_layout.resource", + format!("page {number} has an unsupported image relationship"), + ) + } + }; + required( + (number == 1 && image_item.2 == "cover-image") + || (number > 1 && image_item.2.is_empty()), + "ebook.fixed_layout.cover", + "the first ordered page image must be the only declared cover", + )?; + let (digest, width_px, height_px) = inspect_image(archive, &image_item.0, format)?; + let page_text = read_text(archive, &page_path, "ebook.fixed_layout.xml")?; + let page = parse_xml(&page_text)?; + validate_page(&page, &page_path, &image_item.0, width_px, height_px)?; + pages.push(FixedLayoutPageEvidence { + page: page_path, + image: image_item.0.clone(), + image_sha256: digest, + width_px, + height_px, + }); + } + let nav_text = read_text(archive, "EPUB/nav.xhtml", "ebook.fixed_layout.navigation")?; + let nav = parse_xml(&nav_text)?; + validate_navigation(&nav, &pages)?; + let css = read_text(archive, "EPUB/styles.css", "ebook.fixed_layout.resource")?; + required( + css == crate::fixed_layout_epub::generated_stylesheet(), + "ebook.fixed_layout.unsafe_markup", + "stylesheet differs from the exact generated route", + )?; + let mut expected = vec![ + "mimetype".to_string(), + "META-INF/container.xml".to_string(), + "EPUB/book.opf".to_string(), + "EPUB/nav.xhtml".to_string(), + "EPUB/styles.css".to_string(), + ]; + for page in &pages { + expected.push(page.page.clone()); + expected.push(page.image.clone()); + } + required( + names == expected, + "ebook.fixed_layout.member", + "ZIP member order or membership differs from the declared page sequence", + )?; + Ok(pages) +} + +/// `zip` keeps an index by filename and collapses duplicate central names. +/// Scan the raw directory independently before trusting its indexed view. +fn validate_central_directory(path: &Path, offset: u64, indexed_count: usize) -> Checked<()> { + let mut input = std::fs::File::open(path).map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("cannot read ZIP central directory: {error}"), + })?; + input + .seek(SeekFrom::Start(offset)) + .map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("cannot seek ZIP central directory: {error}"), + })?; + let mut names = HashSet::new(); + let mut count = 0_usize; + loop { + let mut signature = [0_u8; 4]; + input.read_exact(&mut signature).map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("truncated ZIP central directory: {error}"), + })?; + if signature != [0x50, 0x4b, 0x01, 0x02] { + required( + signature == [0x50, 0x4b, 0x05, 0x06] && count == indexed_count && count >= 7, + "ebook.fixed_layout.member", + "ZIP central directory has an unexpected record count or terminator", + )?; + return Ok(()); + } + count += 1; + required( + count <= MAX_MEMBERS, + "ebook.fixed_layout.bounds", + "ZIP central directory exceeds member bound", + )?; + let mut header = [0_u8; 42]; + input.read_exact(&mut header).map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("truncated ZIP central header: {error}"), + })?; + let name_len = u16::from_le_bytes([header[24], header[25]]) as usize; + let extra_len = u16::from_le_bytes([header[26], header[27]]) as u64; + let comment_len = u16::from_le_bytes([header[28], header[29]]) as u64; + required( + name_len > 0 && name_len <= 256, + "ebook.fixed_layout.member", + "ZIP central member name is empty or exceeds 256 bytes", + )?; + let mut name = vec![0_u8; name_len]; + input.read_exact(&mut name).map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("truncated ZIP central filename: {error}"), + })?; + required( + names.insert(name.to_ascii_lowercase()), + "ebook.fixed_layout.member", + "ZIP central directory repeats a member name", + )?; + input + .seek(SeekFrom::Current((extra_len + comment_len) as i64)) + .map_err(|error| Refusal { + code: "ebook.fixed_layout.member", + message: format!("truncated ZIP central extra/comment: {error}"), + })?; + } +} + +fn safe_member_name(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains("//") + && value + .split('/') + .all(|part| part != "." && part != ".." && !part.is_empty()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')) +} + +fn resolve_href(base: &str, href: &str) -> Checked { + if href.is_empty() + || href.starts_with('/') + || href.starts_with("//") + || href.contains(['\\', ':', '%', '#', '?']) + { + return refuse( + "ebook.fixed_layout.unsafe_markup", + format!("unsafe or external resource locator: {href}"), + ); + } + let mut parts = base.split('/').collect::>(); + parts.pop(); + for part in href.split('/') { + match part { + "" | "." => { + return refuse( + "ebook.fixed_layout.unsafe_markup", + "empty or ambiguous resource path component", + ) + } + ".." => { + if parts.pop().is_none() { + return refuse( + "ebook.fixed_layout.unsafe_markup", + "resource escapes the EPUB root", + ); + } + } + _ if part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) => + { + parts.push(part) + } + _ => { + return refuse( + "ebook.fixed_layout.unsafe_markup", + "resource contains unsupported path characters", + ) + } + } + } + required( + !parts.is_empty(), + "ebook.fixed_layout.unsafe_markup", + "resource escapes EPUB root", + )?; + Ok(parts.join("/")) +} + +fn read_text( + archive: &mut ZipArchive, + name: &str, + code: &'static str, +) -> Checked { + let member = archive.by_name(name).map_err(|_| Refusal { + code, + message: format!("required ZIP member is missing: {name}"), + })?; + required( + member.size() <= MAX_XML_BYTES, + "ebook.fixed_layout.bounds", + format!("XML/CSS member exceeds 2 MiB: {name}"), + )?; + let mut text = String::new(); + member + .take(MAX_XML_BYTES + 1) + .read_to_string(&mut text) + .map_err(|error| Refusal { + code, + message: format!("unreadable UTF-8 member {name}: {error}"), + })?; + required( + text.len() as u64 <= MAX_XML_BYTES, + "ebook.fixed_layout.bounds", + "XML/CSS read exceeded bound", + )?; + Ok(text) +} + +fn parse_xml(text: &str) -> Checked> { + // This route emits only XML declarations, elements, text, and attributes. + // DTD, entities, comments, CDATA, and other processing instructions are + // deliberately refused before the parser sees them. + required( + !text.contains(", namespace: &str, local_name: &str) -> bool { + node.is_element() + && node.tag_name().namespace() == Some(namespace) + && node.tag_name().name() == local_name +} + +fn only_attributes(node: Node<'_, '_>, allowed: &[(&str, Option<&str>)]) -> Checked<()> { + required( + node.attributes().all(|attribute| { + allowed.iter().any(|(name, namespace)| { + attribute.name() == *name && attribute.namespace() == *namespace + }) + }), + "ebook.fixed_layout.unsafe_markup", + "element contains an undeclared attribute", + ) +} + +fn one_child<'a, 'input>( + node: Node<'a, 'input>, + namespace: &str, + local_name: &str, + code: &'static str, +) -> Checked> { + let mut candidates = node + .children() + .filter(|child| element(*child, namespace, local_name)); + let first = candidates.next(); + if first.is_none() || candidates.next().is_some() { + return refuse( + code, + format!("expected exactly one {local_name} child in the {namespace} namespace"), + ); + } + Ok(first.expect("checked above")) +} + +fn require_meta( + metadata: Node<'_, '_>, + property: &str, + accept: impl Fn(&str) -> bool, + code: &'static str, +) -> Checked<()> { + let values = metadata + .children() + .filter(|node| { + element(*node, OPF_NS, "meta") && node.attribute("property") == Some(property) + }) + .map(|node| node.text().unwrap_or_default().trim().to_string()) + .collect::>(); + required( + values.len() == 1 && accept(&values[0]), + code, + format!("missing, duplicated, or invalid {property} metadata"), + ) +} + +fn inspect_image( + archive: &mut ZipArchive, + name: &str, + format: Format, +) -> Checked<(String, u32, u32)> { + let mut member = archive.by_name(name).map_err(|_| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("declared image is missing: {name}"), + })?; + required( + member.size() <= MAX_IMAGE_BYTES, + "ebook.fixed_layout.bounds", + format!("image exceeds 128 MiB: {name}"), + )?; + let mut temporary = tempfile::NamedTempFile::new().map_err(|error| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("cannot inspect image: {error}"), + })?; + let mut hasher = Sha256::new(); + let mut copied = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = member.read(&mut buffer).map_err(|error| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("corrupt image member {name}: {error}"), + })?; + if count == 0 { + break; + } + copied = copied.checked_add(count as u64).ok_or_else(|| Refusal { + code: "ebook.fixed_layout.bounds", + message: "image size overflow".to_string(), + })?; + required( + copied <= MAX_IMAGE_BYTES, + "ebook.fixed_layout.bounds", + "image exceeded read bound", + )?; + hasher.update(&buffer[..count]); + temporary + .write_all(&buffer[..count]) + .map_err(|error| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("cannot stage inspected image: {error}"), + })?; + } + required( + copied == member.size(), + "ebook.fixed_layout.resource", + "image size differs from ZIP declaration", + )?; + let info = inspect_print_image(temporary.path(), format).map_err(|error| Refusal { + code: "ebook.fixed_layout.resource", + message: format!("image {name} failed bounded PNG/JPEG preflight: {error:#}"), + })?; + Ok(( + format!("{:x}", hasher.finalize()), + info.width_px, + info.height_px, + )) +} + +fn validate_page( + document: &Document<'_>, + page_path: &str, + image_path: &str, + width_px: u32, + height_px: u32, +) -> Checked<()> { + let html = document.root_element(); + required( + element(html, XHTML_NS, "html"), + "ebook.fixed_layout.xml", + "page root must be XHTML", + )?; + only_attributes(html, &[("lang", Some(XML_NS))])?; + required( + html.children().filter(|node| node.is_element()).count() == 2, + "ebook.fixed_layout.unsafe_markup", + "page document contains undeclared markup outside head and body", + )?; + let head = one_child(html, XHTML_NS, "head", "ebook.fixed_layout.xml")?; + let body = one_child(html, XHTML_NS, "body", "ebook.fixed_layout.xml")?; + only_attributes(head, &[])?; + only_attributes(body, &[])?; + required( + head.children().filter(|node| node.is_element()).count() == 3 + && one_child(head, XHTML_NS, "title", "ebook.fixed_layout.xml")? + .text() + .is_some_and(|text| !text.trim().is_empty()), + "ebook.fixed_layout.unsafe_markup", + "page head contains unsupported markup or no title", + )?; + only_attributes( + one_child(head, XHTML_NS, "title", "ebook.fixed_layout.xml")?, + &[], + )?; + let viewport = one_child(head, XHTML_NS, "meta", "ebook.fixed_layout.viewport")?; + only_attributes(viewport, &[("name", None), ("content", None)])?; + required( + viewport.attribute("name") == Some("viewport") + && viewport.attribute("content") + == Some(format!("width={width_px}, height={height_px}").as_str()), + "ebook.fixed_layout.viewport", + "page viewport differs from the decoded PNG/JPEG dimensions", + )?; + let style = one_child(head, XHTML_NS, "link", "ebook.fixed_layout.resource")?; + only_attributes(style, &[("rel", None), ("type", None), ("href", None)])?; + required( + style.attribute("rel") == Some("stylesheet") + && style.attribute("type") == Some("text/css") + && resolve_href(page_path, style.attribute("href").unwrap_or_default())? + == "EPUB/styles.css", + "ebook.fixed_layout.resource", + "page stylesheet is not the declared local stylesheet", + )?; + let image = one_child(body, XHTML_NS, "img", "ebook.fixed_layout.resource")?; + only_attributes(image, &[("class", None), ("src", None), ("alt", None)])?; + required( + resolve_href(page_path, image.attribute("src").unwrap_or_default())? == image_path, + "ebook.fixed_layout.resource", + "page image reference does not match its ordered manifest image", + )?; + required( + image + .attribute("alt") + .is_some_and(|value| !value.trim().is_empty() && value.len() <= 4096), + "ebook.fixed_layout.accessibility", + "page image is missing its bounded accessible description", + )?; + required( + body.children().filter(|node| node.is_element()).count() == 1, + "ebook.fixed_layout.unsafe_markup", + "page body contains undeclared content or resources", + ) +} + +fn validate_navigation(document: &Document<'_>, pages: &[FixedLayoutPageEvidence]) -> Checked<()> { + let html = document.root_element(); + required( + element(html, XHTML_NS, "html"), + "ebook.fixed_layout.navigation", + "navigation document must be XHTML", + )?; + only_attributes(html, &[("lang", Some(XML_NS))])?; + let head = one_child(html, XHTML_NS, "head", "ebook.fixed_layout.navigation")?; + only_attributes(head, &[])?; + required( + head.children().filter(|node| node.is_element()).count() == 1 + && one_child(head, XHTML_NS, "title", "ebook.fixed_layout.navigation")? + .text() + .is_some_and(|text| !text.trim().is_empty()), + "ebook.fixed_layout.navigation", + "navigation head must have only a publication title", + )?; + only_attributes( + one_child(head, XHTML_NS, "title", "ebook.fixed_layout.navigation")?, + &[], + )?; + let body = one_child(html, XHTML_NS, "body", "ebook.fixed_layout.navigation")?; + only_attributes(body, &[])?; + required( + html.children().filter(|node| node.is_element()).count() == 2 + && body.children().filter(|node| node.is_element()).count() == 2, + "ebook.fixed_layout.navigation", + "navigation document contains undeclared markup", + )?; + for kind in ["toc", "page-list"] { + let candidates = body + .children() + .filter(|node| { + element(*node, XHTML_NS, "nav") && node.attribute((EPUB_NS, "type")) == Some(kind) + }) + .collect::>(); + required( + candidates.len() == 1, + "ebook.fixed_layout.navigation", + format!("navigation requires exactly one {kind}"), + )?; + let nav = candidates[0]; + only_attributes(nav, &[("type", Some(EPUB_NS)), ("id", None)])?; + let heading = if kind == "toc" { "h1" } else { "h2" }; + let title = one_child(nav, XHTML_NS, heading, "ebook.fixed_layout.navigation")?; + let list = one_child(nav, XHTML_NS, "ol", "ebook.fixed_layout.navigation")?; + only_attributes(title, &[])?; + only_attributes(list, &[])?; + required( + nav.children().filter(|node| node.is_element()).count() == 2 + && title.text().is_some_and(|text| !text.trim().is_empty()), + "ebook.fixed_layout.navigation", + "navigation has unsupported content or an empty heading", + )?; + let entries = list + .children() + .filter(|node| node.is_element()) + .collect::>(); + required( + entries.len() == pages.len(), + "ebook.fixed_layout.navigation", + format!("{kind} links do not cover all ordered pages"), + )?; + for (index, (entry, page)) in entries.iter().zip(pages).enumerate() { + only_attributes(*entry, &[])?; + required( + element(*entry, XHTML_NS, "li") + && entry.children().filter(|node| node.is_element()).count() == 1, + "ebook.fixed_layout.navigation", + "navigation list must contain only single-link entries", + )?; + let link = one_child(*entry, XHTML_NS, "a", "ebook.fixed_layout.navigation")?; + only_attributes(link, &[("href", None)])?; + required( + resolve_href("EPUB/nav.xhtml", link.attribute("href").unwrap_or_default())? + == page.page + && !link.text().unwrap_or_default().trim().is_empty(), + "ebook.fixed_layout.navigation", + format!("{kind} link {} differs from spine order", index + 1), + )?; + } + } + Ok(()) +} diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index e6b1420..6d587ff 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod ebook; pub mod error; pub mod evidence; pub mod fixed_layout_epub; +mod fixed_layout_epub_validate; pub mod font; pub mod graph; pub mod hygiene; diff --git a/crates/renderflow-core/tests/fixed_layout_epub.rs b/crates/renderflow-core/tests/fixed_layout_epub.rs index e09610d..ed1c1b3 100644 --- a/crates/renderflow-core/tests/fixed_layout_epub.rs +++ b/crates/renderflow-core/tests/fixed_layout_epub.rs @@ -7,11 +7,15 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::{atomic::AtomicBool, Arc}; +use renderflow::ebook::{ + inspect_ebook, inspect_ebook_with_manifest, EbookCapabilityContract, EbookDiagnosticSeverity, + EbookLayout, EbookProvenanceStatus, +}; use renderflow::evidence::{sha256_serialized, ArtifactRole, RunState, StepState}; use renderflow::planning::{execute, resolve, CanonicalExecutionResult, PlanningRequest}; use sha2::{Digest, Sha256}; use tempfile::TempDir; -use zip::{CompressionMethod, ZipArchive}; +use zip::{write::SimpleFileOptions, CompressionMethod, ZipArchive, ZipWriter}; const PNG_FIRST: &[u8] = include_bytes!("fixtures/print-pdf/page-001.png"); const PNG_SECOND: &[u8] = include_bytes!("fixtures/print-pdf/page-002.png"); @@ -139,6 +143,43 @@ fn published_epub(result: &CanonicalExecutionResult) -> &Path { path } +fn rewrite_member(members: &mut [(String, Vec)], name: &str, before: &str, after: &str) { + let content = members + .iter_mut() + .find(|(path, _)| path == name) + .unwrap_or_else(|| panic!("missing ZIP member {name}")); + let text = String::from_utf8(content.1.clone()).unwrap(); + assert!( + text.contains(before), + "missing replacement marker in {name}: {before}" + ); + content.1 = text.replacen(before, after, 1).into_bytes(); +} + +fn tampered_epub( + source: &Path, + destination: &Path, + change: impl FnOnce(&mut Vec<(String, Vec)>), +) { + let mut original = ZipArchive::new(File::open(source).unwrap()).unwrap(); + let mut members = Vec::new(); + for index in 0..original.len() { + let mut entry = original.by_index(index).unwrap(); + let name = entry.name().to_string(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + members.push((name, bytes)); + } + change(&mut members); + let mut archive = ZipWriter::new(File::create(destination).unwrap()); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + for (name, bytes) in members { + archive.start_file(name, options).unwrap(); + std::io::Write::write_all(&mut archive, &bytes).unwrap(); + } + archive.finish().unwrap(); +} + fn assert_publication_sidecars(result: &CanonicalExecutionResult, fixture: &Fixture) { let metadata = fixture.dir.path().join("dist/metadata"); for name in [ @@ -518,6 +559,395 @@ fn metadata_and_page_order_change_the_execution_plan_identity() { ); } +#[test] +fn native_inspection_proves_generated_package_and_honest_capabilities() { + for (format, direction) in [("png", "ltr"), ("jpeg", "rtl")] { + let fixture = Fixture::new(format, direction); + let result = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + false, + ) + .unwrap(); + assert_eq!(result.run_manifest.state, RunState::Complete); + let inspection = inspect_ebook(published_epub(&result), false).unwrap(); + assert!(inspection.valid, "{:?}", inspection.diagnostics); + assert_eq!(inspection.layout, EbookLayout::PrePaginated); + assert_eq!(inspection.spine_items, 2); + assert_eq!(inspection.xhtml_documents, 3); + assert!(inspection.navigation); + assert!(inspection.page_list); + assert!(inspection.metadata.title); + assert!(inspection.metadata.creator); + assert!(inspection.metadata.language); + assert!(inspection.metadata.identifier); + assert!(inspection.metadata.rights); + assert!(inspection.accessibility.accessibility_summary); + assert!(inspection.accessibility.access_modes > 0); + assert!(inspection.accessibility.accessibility_features > 0); + assert!(inspection.accessibility.accessibility_hazards > 0); + assert!(inspection.epubcheck.is_none()); + fixture.unchanged(); + } + + let capability = EbookCapabilityContract::builtin(); + assert_eq!(capability.schema, "renderflow.ebook-capabilities/v1"); + assert!(capability.epub_fixed_layout_generation); + assert!(!capability.kepub_fixed_layout_generation); + let route = capability.fixed_layout_epub_route.as_ref().unwrap(); + assert_eq!(route.capability, "ebook.generate.epub.fixed-layout"); + assert_eq!(route.provider_id, "tool.renderflow-epub"); + assert_eq!( + route.source_formats, + ["png".to_string(), "jpeg".to_string()] + ); + assert_eq!(route.target_format, "epub"); + assert!(route.ordered_collection_required); + assert!(route.homogeneous_local_sources_required); + assert!(route.explicit_execution_policy_required); + assert_eq!( + route.page_progression_directions, + ["ltr".to_string(), "rtl".to_string()] + ); + assert_eq!(route.spread_policies, ["none".to_string()]); + assert!(route.native_validation); + assert!(route.optional_epubcheck_v5); +} + +#[test] +fn native_inspection_rejects_adversarial_package_mutations() { + let fixture = Fixture::new("png", "ltr"); + let result = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + false, + ) + .unwrap(); + assert_eq!(result.run_manifest.state, RunState::Complete); + let generated = published_epub(&result); + let cases = [ + ("duplicate_spine_item", "spine"), + ("missing_page_list", "navigation"), + ("reversed_page_list", "navigation"), + ("missing_cover", "cover"), + ("wrong_cover", "cover"), + ("wrong_viewport", "viewport"), + ("empty_alt_text", "accessibility"), + ("missing_accessibility_summary", "accessibility"), + ("active_page_head", "unsafe_markup"), + ("upper_case_css_url", "unsafe_markup"), + ("unsafe_script", "unsafe_markup"), + ("external_image", "unsafe_markup"), + ("external_srcset", "unsafe_markup"), + ("external_xml_base", "unsafe_markup"), + ("inline_style", "unsafe_markup"), + ("malformed_xml", "xml"), + ("external_entity", "unsafe_markup"), + ("missing_image", "resource"), + ("unreadable_image", "resource"), + ("duplicate_zip_member", "member"), + ("path_traversal", "member"), + ("wrong_mimetype", "mimetype"), + ("mimetype_not_first", "mimetype"), + ]; + for (case, expected_code) in cases { + let tampered = fixture.dir.path().join(format!("tampered-{case}.epub")); + tampered_epub(generated, &tampered, |members| { + match case { + "duplicate_spine_item" => rewrite_member( + members, + "EPUB/book.opf", + "", + "", + ), + "missing_page_list" => rewrite_member( + members, + "EPUB/nav.xhtml", + "