diff --git a/xtask/src/macos_integration.rs b/xtask/src/macos_integration.rs new file mode 100644 index 00000000..4e6b26ce --- /dev/null +++ b/xtask/src/macos_integration.rs @@ -0,0 +1,344 @@ +//! Read-only diagnostics for the macOS document-integration boundary. +//! +//! The command intentionally reports evidence and limitations instead of trying to register an +//! application, refresh LaunchServices, index a file, or install a Quick Look provider. That +//! keeps it safe to run from CI, a release checkout, or a developer's normal machine. + +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, anyhow, bail}; + +const INFO_KEYS: [&str; 8] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate"]; +const MAX_PDF_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PDF_TEXT_BYTES: usize = 8 * 1024 * 1024; +const MAX_PDF_PAGES: usize = 32; +const MAX_METADATA_CHARS: usize = 1024; +const MAX_PROBE_OUTPUT_BYTES: usize = 4096; + +#[derive(Debug, Default, PartialEq, Eq)] +struct Options { + help: bool, + bundle: Option, + pdf: Option, +} + +#[derive(Debug)] +struct Probe { + status: Option, + stdout: String, + stderr: String, + error: Option, +} + +/// `cargo xtask macos-integration [--bundle APP] [--pdf FILE]`. +pub fn run(args: &[String]) -> anyhow::Result<()> { + let options = parse_args(args)?; + if options.help { + println!("Usage: cargo xtask macos-integration [--bundle PdfCraft.app] [--pdf file.pdf]"); + return Ok(()); + } + let mut report = String::new(); + writeln!(report, "PdfCraft macOS integration diagnostics")?; + writeln!(report, "target: {} / {}", std::env::consts::OS, std::env::consts::ARCH)?; + writeln!(report, "os version: {}", os_version())?; + + match options.bundle.as_deref() { + Some(bundle) => write_bundle_report(&mut report, bundle)?, + None => writeln!(report, "bundle: not supplied (use --bundle /path/to/PdfCraft.app)")?, + } + + write_tool_report(&mut report, "quick look", "qlmanage", &["-m", "plugins"])?; + match options.pdf.as_deref() { + Some(pdf) => { + write_pdf_report(&mut report, pdf)?; + write_spotlight_report(&mut report, pdf)?; + } + None => { + writeln!(report, "pdf: not supplied (use --pdf /path/to/synthetic.pdf)")?; + writeln!(report, "spotlight: skipped (a PDF path is required for test-import)")?; + } + } + + print!("{report}"); + Ok(()) +} + +fn parse_args(args: &[String]) -> anyhow::Result { + let mut options = Options::default(); + let mut it = args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--bundle" => options.bundle = Some(next_path(&mut it, "--bundle")?), + "--pdf" => options.pdf = Some(next_path(&mut it, "--pdf")?), + "-h" | "--help" => options.help = true, + other => bail!("unknown argument `{other}` (expected --bundle, --pdf or --help)"), + } + } + Ok(options) +} + +fn next_path<'a>(it: &mut impl Iterator, flag: &str) -> anyhow::Result { + it.next().map(PathBuf::from).ok_or_else(|| anyhow!("{flag} needs a path")) +} + +fn os_version() -> String { + let probe = probe("sw_vers", &["-productVersion"]); + if probe.status == Some(0) { + let value = combined_output(&probe).trim().to_owned(); + if !value.is_empty() { + return value; + } + } + "unavailable".into() +} + +fn write_bundle_report(report: &mut String, bundle: &Path) -> anyhow::Result<()> { + writeln!(report, "bundle: {}", bundle.display())?; + if !bundle.is_dir() { + writeln!(report, " status: missing or not a directory")?; + return Ok(()); + } + let contents = bundle.join("Contents"); + let plist = contents.join("Info.plist"); + let plist_text = fs::read_to_string(&plist).ok(); + if let Some(text) = plist_text.as_deref() { + let bundle_id = plist_value(text, "CFBundleIdentifier").unwrap_or_else(|| "unavailable".into()); + let minimum = plist_value(text, "LSMinimumSystemVersion").unwrap_or_else(|| "unavailable".into()); + writeln!(report, " bundle identifier: {bundle_id}")?; + writeln!(report, " minimum macOS: {minimum}")?; + writeln!(report, " PDF registration: {}", pdf_registration(text))?; + let executable = plist_value(text, "CFBundleExecutable").unwrap_or_else(|| "PdfCraft".into()); + write_architecture_report(report, &contents.join("MacOS").join(executable))?; + } else if plist.exists() { + writeln!(report, " Info.plist: present but not UTF-8 XML (binary plist inspection unavailable)")?; + } else { + writeln!(report, " Info.plist: missing")?; + } + + let plugins = contents.join("PlugIns"); + if plugins.is_dir() { + let entries = bounded_entries(&plugins, 64)?; + if entries.is_empty() { + writeln!(report, " nested code: PlugIns directory is empty")?; + } else { + writeln!(report, " nested code: {}", entries.join(", "))?; + } + } else { + writeln!(report, " nested code: none under Contents/PlugIns")?; + } + + let signature = probe("codesign", &["--verify", "--deep", "--strict", bundle.to_string_lossy().as_ref()]); + writeln!(report, " signature: {}", probe_status(&signature, "valid"))?; + Ok(()) +} + +fn write_architecture_report(report: &mut String, executable: &Path) -> anyhow::Result<()> { + if !executable.is_file() { + writeln!(report, " architectures: executable missing ({})", executable.display())?; + return Ok(()); + } + let probe = probe("lipo", &["-info", executable.to_string_lossy().as_ref()]); + writeln!(report, " architectures: {}", probe_status(&probe, "reported"))?; + Ok(()) +} + +fn write_tool_report(report: &mut String, label: &str, program: &str, args: &[&str]) -> anyhow::Result<()> { + let probe = probe(program, args); + let status = if probe.status == Some(0) && useful_output(&probe).is_none() { + "available (no output returned)".into() + } else { + probe_status(&probe, "available") + }; + writeln!(report, "{label}: {status}")?; + if let Some(output) = useful_output(&probe) { + writeln!(report, " output: {}", output.replace('\n', "\\n"))?; + } + Ok(()) +} + +fn write_spotlight_report(report: &mut String, pdf: &Path) -> anyhow::Result<()> { + let probe = probe("mdimport", &["-t", "-d2", pdf.to_string_lossy().as_ref()]); + let status = if probe.status == Some(0) && useful_output(&probe).is_none_or(|o| o.contains("Nothing returned by server")) { + "no metadata returned".to_owned() + } else { + probe_status(&probe, "test-import succeeded") + }; + writeln!(report, "spotlight: {status}")?; + if let Some(output) = useful_output(&probe) { + writeln!(report, " output: {}", output.replace('\n', "\\n"))?; + } + Ok(()) +} + +fn write_pdf_report(report: &mut String, pdf: &Path) -> anyhow::Result<()> { + let metadata = fs::metadata(pdf).with_context(|| format!("stat PDF {}", pdf.display()))?; + if metadata.len() > MAX_PDF_BYTES { + bail!("PDF {} exceeds the {MAX_PDF_BYTES}-byte diagnostic limit", pdf.display()); + } + let bytes = fs::read(pdf).with_context(|| format!("read PDF {}", pdf.display()))?; + let doc = lopdf::Document::load_mem(&bytes).with_context(|| format!("parse PDF {}", pdf.display()))?; + let pages = doc.get_pages(); + writeln!(report, "pdf: {} ({} bytes)", pdf.display(), bytes.len())?; + writeln!(report, " pages: {}", pages.len())?; + writeln!(report, " metadata:")?; + for key in INFO_KEYS { + let value = info_value(&doc, key).unwrap_or_else(|| "".into()); + writeln!(report, " {key}: {value}")?; + } + let page_numbers: Vec = pages.keys().copied().take(MAX_PDF_PAGES).collect(); + if page_numbers.is_empty() { + writeln!(report, " text: no pages")?; + } else { + match doc.extract_text_with_limit(&page_numbers, MAX_PDF_TEXT_BYTES) { + Ok(text) => { + let sample: String = text.chars().take(240).collect(); + writeln!(report, " text: {} bytes from first {} page(s); sample: {:?}", text.len(), page_numbers.len(), sample)?; + } + Err(err) => writeln!(report, " text: unavailable ({err})")?, + } + if pages.len() > MAX_PDF_PAGES { + writeln!(report, " text limit: first {MAX_PDF_PAGES} pages inspected")?; + } + } + Ok(()) +} + +fn info_value(doc: &lopdf::Document, key: &str) -> Option { + let info = doc.trailer.get(b"Info").ok()?; + let (_, info) = doc.dereference(info).ok()?; + let dict = info.as_dict().ok()?; + let value = dict.get(key.as_bytes()).ok()?; + let (_, value) = doc.dereference(value).ok()?; + let bytes = value.as_str().ok()?; + Some(bounded_text(&pdf_string(bytes), MAX_METADATA_CHARS)) +} + +fn pdf_string(bytes: &[u8]) -> String { + if let Some(utf16) = bytes.strip_prefix(&[0xfe, 0xff]) { + let units: Vec = utf16.as_chunks::<2>().0.iter().map(|pair| u16::from_be_bytes(*pair)).collect(); + return String::from_utf16_lossy(&units); + } + String::from_utf8_lossy(bytes).into_owned() +} + +fn plist_value(plist: &str, key: &str) -> Option { + let key = format!("{key}"); + let after = plist.split_once(&key)?.1; + let start = after.find("")? + "".len(); + let end = after[start..].find("")? + start; + Some(after[start..end].to_owned()) +} + +fn pdf_registration(plist: &str) -> String { + let docs = plist.split_once("CFBundleDocumentTypes").map(|(_, rest)| rest).unwrap_or_default(); + let pdf = docs + .split("") + .map(|chunk| chunk.split("").next().unwrap_or_default()) + .find(|chunk| chunk.contains("com.adobe.pdf")); + match pdf { + Some(entry) => { + let role = if entry.contains("Editor") { "Editor" } else { "unknown role" }; + let rank = if entry.contains("Alternate") { "Alternate" } else { "unknown rank" }; + format!("com.adobe.pdf (role={role}, rank={rank})") + } + None => "com.adobe.pdf not declared".into(), + } +} + +fn bounded_entries(dir: &Path, limit: usize) -> anyhow::Result> { + let mut entries = fs::read_dir(dir) + .with_context(|| format!("read nested-code directory {}", dir.display()))? + .take(limit) + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + entries.sort(); + Ok(entries) +} + +fn probe(program: &str, args: &[&str]) -> Probe { + match Command::new(program).args(args).output() { + Ok(output) => Probe { + status: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + error: None, + }, + Err(error) => Probe { status: None, stdout: String::new(), stderr: String::new(), error: Some(error.to_string()) }, + } +} + +fn probe_status(probe: &Probe, success: &str) -> String { + match (probe.status, probe.error.as_deref()) { + (Some(0), _) => success.into(), + (Some(code), _) => format!("failed (exit {code})"), + (None, Some(error)) => format!("unavailable ({error})"), + (None, None) => "unavailable".into(), + } +} + +fn combined_output(probe: &Probe) -> String { + let mut output = probe.stdout.clone(); + if !probe.stderr.is_empty() { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(&probe.stderr); + } + output +} + +fn useful_output(probe: &Probe) -> Option { + let output = combined_output(probe).trim().to_owned(); + (!output.is_empty()).then(|| output.chars().take(MAX_PROBE_OUTPUT_BYTES).collect()) +} + +fn bounded_text(value: &str, limit: usize) -> String { + let mut chars = value.chars(); + let mut bounded: String = chars.by_ref().take(limit).collect(); + if chars.next().is_some() { + bounded.push('…'); + } + bounded +} + +#[cfg(test)] +mod tests { + use super::{Options, parse_args, pdf_registration, plist_value}; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn parses_optional_paths() { + assert_eq!( + parse_args(&args(&["--bundle", "PdfCraft.app", "--pdf", "fixture.pdf"])).expect("arguments"), + Options { help: false, bundle: Some("PdfCraft.app".into()), pdf: Some("fixture.pdf".into()) } + ); + } + + #[test] + fn rejects_missing_path() { + assert!(parse_args(&args(&["--pdf"])).is_err()); + } + + #[test] + fn reads_pdf_bundle_values_without_invoking_macos_tools() { + let plist = r#" + CFBundleIdentifierai.storyteller.pdfcraft + LSMinimumSystemVersion11.0 + CFBundleDocumentTypes + CFBundleTypeRoleEditor + LSHandlerRankAlternate + LSItemContentTypescom.adobe.pdf + + "#; + assert_eq!(plist_value(plist, "CFBundleIdentifier").as_deref(), Some("ai.storyteller.pdfcraft")); + assert_eq!(plist_value(plist, "LSMinimumSystemVersion").as_deref(), Some("11.0")); + assert_eq!(pdf_registration(plist), "com.adobe.pdf (role=Editor, rank=Alternate)"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 080a747f..70741d22 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod demo_pdf; mod fuzz; mod gates; mod layers; +mod macos_integration; mod parity; mod screenshots; mod version; @@ -26,6 +27,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check), ("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run), ("parity", "Validate parity/acrobat-features.toml against the registry, tools and tests; report progress (--json, --partial)", parity::run), + ( + "macos-integration", + "Report macOS bundle, Quick Look, Spotlight and PDF integration diagnostics without mutating system state", + macos_integration::run, + ), ("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle), ("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run), ("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models),