Files
effectcraft/xtask/src/pdf_deps.rs
skylar taylor-barrickandCodex c2f790b1d9 refactor(pdf): share structure parsing through a bounded adapter
Load PDF document objects through the pinned craft-pdf parser and convert them into EffectCraft's existing drawing model with bounded traversal, recovery and retained data. Preserve generation numbers, stream limits, encrypted-document refusal and reported repairs.

Retain app-owned drawing, EPS, fonts and content/image decoding. Assert early excessive-nesting rejection without partial art, and cover tolerant arrays on native and WASM.

Validated with the22-stage local matrix and exact-head FreeBSD, Windows ARM64 and packaging checks.

Co-authored-by: Codex <noreply@openai.com>
2026-10-15 16:35:49 -06:00

118 lines
4.9 KiB
Rust

//! Pin the shared parser and reject interpreter features that would embed backend assets.
use std::path::Path;
use std::process::Command;
use serde_json::Value;
const REV: &str = "ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d";
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2")];
pub fn run(root: &Path) -> Result<(), String> {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args(["metadata", "--locked", "--format-version", "1"])
.output()
.map_err(|e| format!("cargo metadata: {e}"))?;
if !output.status.success() {
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
validate(&metadata)?;
println!("PDF backend: pinned parser only; no Hayro interpreter, rasterizer or bundled CMaps");
Ok(())
}
fn validate(metadata: &Value) -> Result<(), String> {
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}");
for (name, version) in PACKAGES {
let matches: Vec<_> = packages.iter().filter(|p| p["name"] == *name).collect();
if matches.len() != 1 || matches[0]["version"] != *version || matches[0]["source"] != source {
return Err(format!("{name}: expected exactly {version} from {source}"));
}
}
for (name, allowed) in [("craft-pdf", "read"), ("hayro-syntax", "std")] {
let package = packages.iter().find(|p| p["name"] == name).ok_or_else(|| format!("missing {name}"))?;
let node = nodes.iter().find(|n| n["id"] == package["id"]).ok_or_else(|| format!("{name}: missing resolved node"))?;
let features = node["features"].as_array().ok_or_else(|| format!("{name}: missing resolved features"))?;
if features.len() != 1 || features.first().and_then(Value::as_str) != Some(allowed) {
return Err(format!("{name}: expected only the {allowed} feature"));
}
}
for package in packages {
let name = package["name"].as_str().unwrap_or("");
let active = nodes.iter().any(|node| node["id"] == package["id"]);
if active && matches!(name, "hayro" | "hayro-interpret" | "hayro-cmap" | "krilla") {
return Err(format!("{name}: EffectCraft must use craft-pdf with only the read feature"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn metadata() -> Value {
let packages: Vec<_> = PACKAGES.iter().map(|(name, version)| {
json!({"id": name, "name": name, "version": version, "source": format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})
}).collect();
json!({"packages": packages, "resolve": {"nodes": [
{"id": "hayro-syntax", "features": ["std"]}, {"id": "craft-pdf", "features": ["read"]}
]}})
}
#[test]
fn accepts_the_pinned_parser_only_graph() {
assert!(validate(&metadata()).is_ok());
}
#[test]
fn rejects_unpinned_duplicate_and_missing_parsers() {
let mut m = metadata();
m["packages"][0]["source"] = json!("registry");
assert!(validate(&m).is_err());
let mut m = metadata();
let duplicate = m["packages"][0].clone();
m["packages"].as_array_mut().unwrap().push(duplicate);
assert!(validate(&m).is_err());
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
assert!(validate(&m).is_err());
}
#[test]
fn rejects_shared_reader_source_drift() {
let mut m = metadata();
m["packages"][0]["source"] = Value::Null;
assert!(validate(&m).is_err());
}
#[test]
fn rejects_extra_features_and_missing_active_nodes() {
for (index, feature) in [(0, "images"), (1, "write"), (1, "raster")] {
let mut m = metadata();
m["resolve"]["nodes"][index]["features"].as_array_mut().unwrap().push(json!(feature));
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"]["nodes"].as_array_mut().unwrap().pop();
assert!(validate(&m).is_err());
}
#[test]
fn rejects_backend_asset_features_and_missing_resolution() {
for name in ["hayro", "hayro-interpret", "hayro-cmap", "krilla"] {
let mut m = metadata();
m["packages"].as_array_mut().unwrap().push(json!({"id": name, "name": name}));
m["resolve"]["nodes"].as_array_mut().unwrap().push(json!({"id": name}));
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"] = Value::Null;
assert!(validate(&m).is_err());
}
}