From 978bc28da8481634be0b5da33620fee6bba44d2b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:34:12 +0000 Subject: [PATCH] refactor(cli): split main.rs into src/ New modules: options, commands, check, run, ui. --- apps/pdfcraft-cli/src/check.rs | 206 ++++++ apps/pdfcraft-cli/src/commands.rs | 413 +++++++++++ apps/pdfcraft-cli/src/main.rs | 1066 +---------------------------- apps/pdfcraft-cli/src/options.rs | 148 ++++ apps/pdfcraft-cli/src/run.rs | 186 +++++ apps/pdfcraft-cli/src/ui.rs | 131 ++++ 6 files changed, 1098 insertions(+), 1052 deletions(-) create mode 100644 apps/pdfcraft-cli/src/check.rs create mode 100644 apps/pdfcraft-cli/src/commands.rs create mode 100644 apps/pdfcraft-cli/src/options.rs create mode 100644 apps/pdfcraft-cli/src/run.rs create mode 100644 apps/pdfcraft-cli/src/ui.rs diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs new file mode 100644 index 000000000..1821f8582 --- /dev/null +++ b/apps/pdfcraft-cli/src/check.rs @@ -0,0 +1,206 @@ +use super::commands::{parse_dpi, read}; +use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; +use super::*; + +/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are +/// found so scripts and CI can gate on it, and when the audit itself cannot run. +pub(crate) fn audit(args: &[String]) -> Result<(), CliError> { + validate_options("audit", args, AUDIT_OPTIONS)?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; + let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; + let items: Vec = findings + .into_iter() + .map(|f| { + let rect = f.rect.map(|v| v as f32); + serde_json::json!({ + "page": f.page + 1, + "kind": f.kind.id(), + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), + "covered_text": f.covered_text, + }) + }) + .collect(); + println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); + if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } +} + +/// Child-process body for `check`: prints one JSON line. +pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { + validate_options("check-one", args, CHECK_ONE_OPTIONS)?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; + let start = Instant::now(); + let bytes = read(path)?; + let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { + Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), + Ok(info) => { + let mut r = PageRenderer::new(bytes, RenderConfig::default()); + let mut failed = Vec::new(); + for i in 0..info.pages.len().min(500) { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { + failed.push(format!("p{} text: {e}", i + 1)); + } + if let Some(e) = p.error { + failed.push(format!("p{}: {e}", i + 1)); + } + } + let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; + (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) + } + }; + // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, + // so a panic in cos shows up as a crash of this process. + if args.iter().any(|a| a == "--edit") { + edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + } + let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, + "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + Ok(()) +} + +/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both +/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. +fn edit_round_trip(bytes: &[u8]) { + use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + for num in doc.object_numbers().into_iter().take(20_000) { + if let Ok(o) = doc.try_get(num) + && let Object::Stream(s) = &*o + { + let _ = s.decoded(); + } + } + if let Some(root) = doc.root() { + let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); + } + let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; + for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] + .into_iter() + .flatten() + { + if let Ok(again) = Document::open(Arc::new(out)) { + let _ = again.root().map(|r| again.get(r)); + } + } +} + +fn short(s: &str) -> String { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() +} + +fn collect(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + let mut visited = std::collections::HashSet::new(); + while let Some(p) = stack.pop() { + if p.is_dir() { + // Directory aliases and symlink cycles must not scan the same tree repeatedly. + let identity = match p.canonicalize() { + Ok(identity) => identity, + Err(error) => { + eprintln!("{}: could not resolve directory: {error}", p.display()); + continue; + } + }; + if !visited.insert(identity) { + continue; + } + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.flatten().map(|e| e.path())); + } + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + out.push(p); + } + } + out.sort(); + out +} + +pub(crate) fn check(args: &[String]) -> Result<(), CliError> { + validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; + let files = collect(&positional(args, CHECK_OPTIONS)); + if files.is_empty() { + return Err("check: no PDF files found".into()); + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut results = Vec::new(); + let mut counts: std::collections::BTreeMap = Default::default(); + for (n, f) in files.iter().enumerate() { + let r = run_child(&exe, f, &dpi, timeout); + let status = r["status"].as_str().unwrap_or("?").to_string(); + *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; + if status != "ok" { + eprintln!( + "[{}/{}] {status:<28} {} {}", + n + 1, + files.len(), + f.display(), + r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + ); + } + results.push(r); + } + eprintln!("\nchecked {} files: {:?}", files.len(), counts); + if let Some(out) = flag(args, "--json") { + std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { + let file_s = file.to_string_lossy().to_string(); + let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + }; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + if let Some(mut so) = child.stdout.take() { + use std::io::Read; + let _ = so.read_to_string(&mut out); + } + return match serde_json::from_str::(out.trim()) { + Ok(v) => v, + Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), + }; + } + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + } + } +} diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs new file mode 100644 index 000000000..5efec8331 --- /dev/null +++ b/apps/pdfcraft-cli/src/commands.rs @@ -0,0 +1,413 @@ +use super::options::{ + COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, + validate_options, +}; +use super::*; + +pub(crate) fn read(path: &str) -> Result>, String> { + std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) +} + +pub(crate) fn info(args: &[String]) -> Result<(), CliError> { + validate_options("info", args, INFO_OPTIONS)?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); + // Dynamic XFA forms: say what laying the template out gives (what the app shows). + let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) + .then(|| { + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; + let d = s.get(id)?; + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) + }) + .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + let json = serde_json::json!({ + "file": path, + "pdf_version": info.pdf_version, + "file_size": info.file_size, + "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, + "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), + "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, + "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, + "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": xfa_layout, + "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), + "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), + "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), + "warnings": info.warnings, + }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; + Ok(()) +} + +pub(crate) fn text(args: &[String]) -> Result<(), CliError> { + validate_options("text", args, TEXT_OPTIONS)?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; + let mut selected_page = None; + // Validate every supplied value before reading, retaining the first valid selection. + let mut options = args.iter(); + while let Some(option) = options.next() { + if option == "--page" { + let page = options + .next() + .and_then(|value| value.parse::().ok()) + .filter(|page| *page > 0) + .ok_or("bad --page: expected a positive page number")?; + selected_page.get_or_insert(page - 1); + } else if option.starts_with("--") { + // Match positional(): an option's operand is not itself another option. + options.next(); + } + } + let password = flag(args, "--password"); + let mut bytes = read(path)?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + // Worked out from the file as read: `bytes` may become the session's display copy below. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); + // A document the renderer could not open (wrong or missing password, unparseable file) has + // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask + // `inspect` why instead, so a protected file fails the same way `info` does. + if r.page_count() == 0 { + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; + if !info.pages.is_empty() { + return Err("text: the document could not be parsed".into()); + } + } + let pages: Vec = match selected_page { + Some(page) => vec![page], + None => (0..r.page_count()).collect(), + }; + let mut failed: Vec = Vec::new(); + for (n, p) in pages.iter().enumerate() { + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + if let Some(e) = out.error { + let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); + failed.push(p + 1); + continue; + } + if n > 0 { + stdout_line(format_args!("\u{c}"))?; + } + stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; + } + if failed.is_empty() { + Ok(()) + } else { + Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) + } +} + +/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +const MAX_RANGE: usize = 1_000_000; + +/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based +/// indices (#1923). +fn page_list(s: &str) -> Result, String> { + let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); + let mut out = Vec::new(); + for item in s.split(',').map(str::trim) { + match item.split_once('-') { + Some((from, to)) => { + let (from, to) = (number(from, item)?, number(to, item)?); + if to < from { + return Err(format!("bad page range {item:?}: it ends before it starts")); + } + if to - from >= MAX_RANGE { + return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); + } + out.extend(from - 1..to); + } + None => out.push(number(item, item)? - 1), + } + } + Ok(out) +} + +/// The options `edit` understands, for its "unknown option" message. +const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; + +/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). +/// +/// Every argument is parsed before the file is opened: an option this command does not know +/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an +/// unchanged copy and exit 0, which looks like a successful edit (#133). +pub(crate) fn edit(args: &[String]) -> Result<(), CliError> { + use pdfcraft_engine::{Edit, Session}; + let mut path: Option<&str> = None; + let mut out: Option<&str> = None; + let mut password: Option<&str> = None; + let mut full = false; + let mut edits = Vec::new(); + let mut i = 0; + while let Some(arg) = args.get(i).map(String::as_str) { + // Options that take no value, and the input file. + match arg { + "--full" => { + full = true; + i += 1; + continue; + } + a if a.starts_with("--") => {} + a => { + if path.is_some() { + return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); + } + path = Some(a); + i += 1; + continue; + } + } + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; + match arg { + "--out" => out = Some(value), + "--password" => password = Some(value), + "--rotate" => { + let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); + } + "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), + "--move" => { + let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); + } + "--insert-blank" => { + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); + } + "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), + "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), + other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), + } + i += 2; + } + let path = path.ok_or("edit: missing file")?; + let out = out.ok_or("edit: missing --out")?; + let mut session = Session::new(); + let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; + for e in edits { + // A destination past the end must fail like `page_move` / `page_insert_blank` do, not + // silently become an append (#1330). Checked per edit: earlier edits change the count. + let n = session.get(id).map_or(0, |d| d.info.pages.len()); + let destination = match &e { + Edit::MovePages { to, .. } => Some(("--move target", *to)), + Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), + _ => None, + }; + if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { + return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); + } + // Pages count from the document as the earlier options left it, so a page past the end is + // reported with its own option and the current page count (#1550). + let listed = match &e { + Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), + Edit::DeletePages { pages } => Some(("--delete", pages)), + Edit::MovePages { pages, .. } => Some(("--move", pages)), + _ => None, + }; + if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { + return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); + } + session.apply(id, e).map_err(|e| e.to_string())?; + } + let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; + // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). + session.check_save(id, &bytes).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +/// Write a PDF a command produced, through a staging file beside the target (#2166): a write +/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. +fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { + use pdfcraft_platform::staging::{StagingName, write_atomically}; + write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) +} + +fn file_stem(path: &str) -> String { + Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) +} + +pub(crate) fn combine(args: &[String]) -> Result<(), CliError> { + validate_options("combine", args, COMBINE_OPTIONS)?; + let out = flag(args, "--out").ok_or("combine: missing --out")?; + let inputs = positional(args, COMBINE_OPTIONS); + if inputs.len() < 2 { + return Err("combine: give at least two input files".into()); + } + let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; + let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn extract(args: &[String]) -> Result<(), CliError> { + validate_options("extract", args, EXTRACT_OPTIONS)?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; + let out = flag(args, "--out").ok_or("extract: missing --out")?; + let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn split(args: &[String]) -> Result<(), CliError> { + validate_options("split", args, SPLIT_OPTIONS)?; + use pdfcraft_engine::SplitBy; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; + let by = match (flag(args, "--every"), flag(args, "--before")) { + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } + (None, Some(list)) => SplitBy::Before(page_list(list)?), + _ => return Err("split: give either --every N or --before PAGES".into()), + }; + let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let stem = file_stem(path); + let parts = session.split(id, &by).map_err(|e| e.to_string())?; + // An output folder that does not exist yet is created (#249); one that can't be is reported + // as the folder's problem, not as the first part's. + std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; + for (a, b, bytes) in parts { + let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); + write_pdf(&name, &bytes)?; + stdout_line(format_args!("{}", name.display()))?; + } + Ok(()) +} + +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +pub(crate) fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + +pub(crate) fn render(args: &[String]) -> Result<(), CliError> { + validate_options("render", args, RENDER_OPTIONS)?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; + let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. + let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some(ImageFormat::Png), + Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), + Some("tif" | "tiff") => Some(ImageFormat::Tiff), + Some("pam") => None, + _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), + }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` and the display + // copy take either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = + PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); + let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = p.error { + return Err(e.into()); + } + let bytes = match format { + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, + None => { + // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. + let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); + pam.extend_from_slice(&p.rgba); + pam + } + }; + std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + for warning in &p.warnings { + match warning { + pdfcraft_render::RenderWarning::ContentTruncated => { + let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + } + pdfcraft_render::RenderWarning::ColourFallback => { + let _ = + writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); + } + } + } + Ok(()) +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 9ac3b2158..0c7fa9ba2 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -53,6 +53,20 @@ macro_rules! mcp_usage { }; } +mod check; +mod commands; +mod options; +mod run; +mod ui; + +use check::{audit, check, check_one}; +use commands::{combine, edit, extract, info, render, split, text}; +use options::{OptionSpec, flag, validate_options}; +#[cfg(feature = "mcp")] +use run::mcp; +use run::{automation, print_output, run, tools}; +use ui::ui; + /// The full usage, printed by `--help` (also after a command, e.g. `render --help`). const USAGE: &str = concat!( "\ @@ -181,1055 +195,3 @@ fn version() -> Result<(), CliError> { stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) } - -/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are -/// found so scripts and CI can gate on it, and when the audit itself cannot run. -fn audit(args: &[String]) -> Result<(), CliError> { - validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; - let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; - // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` - // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { - None - } else { - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) - }; - let items: Vec = findings - .into_iter() - .map(|f| { - let rect = f.rect.map(|v| v as f32); - serde_json::json!({ - "page": f.page + 1, - "kind": f.kind.id(), - "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), - "covered_text": f.covered_text, - }) - }) - .collect(); - println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); - if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } -} - -fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) -} - -#[derive(Clone, Copy)] -struct OptionSpec { - name: &'static str, - takes_value: bool, -} - -/// The options whose value is a file or folder. Only these refuse a value that is another -/// option: a file named `--password` is never what was meant, while a password can be anything -/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. -/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. -const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; - -/// Whether a token can be the value of a path option. Another option cannot: `--out --password` -/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). -fn can_be_path_value(name: &str, value: Option<&str>) -> bool { - !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) -} - -/// The complaint about a path option whose value is missing or is another option. -fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { - match value { - Some(v) if !v.is_empty() => { - format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") - } - _ => format!("{command}: {name} needs a value"), - } -} - -/// Refuse unknown options and malformed confinement flags before any file is read. -fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { - if specs.iter().any(|spec| spec.name == "--root") { - // `flag` searches every token, including another option's value. Match that lookup so - // a malformed confinement flag cannot be hidden there and fall back to no root. - let mut root_seen = false; - for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { - if root_seen { - return Err(format!("{command}: --root may only be supplied once").into()); - } - root_seen = true; - if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { - return Err(format!("{command}: --root needs a directory value").into()); - } - } - } - let mut i = 0; - while let Some(arg) = args.get(i) { - if let Some(name) = arg.strip_prefix("--") { - let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { - return Err(format!("{command}: unknown option --{name}").into()); - }; - // A file's name is not another option, which also closes the gap the walk left for - // those: an unknown option in that value position was skipped along with it. Other - // values, missing or malformed, are left to the command, which reports them in its - // own terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(spec.name, value) { - return Err(needs_value(command, spec.name, value).into()); - } - 2 - } else { - 1 - }; - } else { - i += 1; - } - } - Ok(()) -} - -const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; -const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; -const EXTRACT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--pages", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const SPLIT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--every", takes_value: true }, - OptionSpec { name: "--before", takes_value: true }, - OptionSpec { name: "--out-dir", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const RENDER_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--page", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; -const CHECK_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--timeout", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--json", takes_value: true }, -]; -const RUN_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--root", takes_value: true }, - OptionSpec { name: "--script", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--report", takes_value: false }, -]; -const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; - -#[cfg(feature = "mcp")] -const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; - -/// The arguments that are not options or their values. Which tokens are values is the spec's -/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing -/// file", the file having been read as `--edit`'s value. -fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { - let mut out = Vec::new(); - let mut i = 0; - while let Some(a) = args.get(i) { - match specs.iter().find(|spec| spec.name == a.as_str()) { - Some(spec) if spec.takes_value => i += 2, - // An option that takes no value, and an unknown one, which `validate_options` has - // already refused by the time this runs. - Some(_) => i += 1, - None if a.starts_with("--") => i += 1, - None => { - out.push(a.as_str()); - i += 1; - } - } - } - out -} - -fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) -} - -fn info(args: &[String]) -> Result<(), CliError> { - validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let pdf_pages = info.pages.len(); - let acroform_fields = info.fields.len(); - // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) - }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); - let json = serde_json::json!({ - "file": path, - "pdf_version": info.pdf_version, - "file_size": info.file_size, - "pages": info.pages.len(), - "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, - "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), - "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, - "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, - "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), - "pdfua": declared.pdfua, - "output_intents": declared.output_intents, - "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": xfa_layout, - "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), - "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), - "transitions": info.transitions.len(), - "custom_properties": info.custom.len(), - "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), - "warnings": info.warnings, - }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; - Ok(()) -} - -fn text(args: &[String]) -> Result<(), CliError> { - validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; - let mut selected_page = None; - // Validate every supplied value before reading, retaining the first valid selection. - let mut options = args.iter(); - while let Some(option) = options.next() { - if option == "--page" { - let page = options - .next() - .and_then(|value| value.parse::().ok()) - .filter(|page| *page > 0) - .ok_or("bad --page: expected a positive page number")?; - selected_page.get_or_insert(page - 1); - } else if option.starts_with("--") { - // Match positional(): an option's operand is not itself another option. - options.next(); - } - } - let password = flag(args, "--password"); - let mut bytes = read(path)?; - // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. - // Worked out from the file as read: `bytes` may become the session's display copy below. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - if doc.xfa.is_some() { - bytes = doc.display.clone(); - } - } - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); - // A document the renderer could not open (wrong or missing password, unparseable file) has - // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask - // `inspect` why instead, so a protected file fails the same way `info` does. - if r.page_count() == 0 { - let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; - if !info.pages.is_empty() { - return Err("text: the document could not be parsed".into()); - } - } - let pages: Vec = match selected_page { - Some(page) => vec![page], - None => (0..r.page_count()).collect(), - }; - let mut failed: Vec = Vec::new(); - for (n, p) in pages.iter().enumerate() { - let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - if let Some(e) = out.error { - let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); - failed.push(p + 1); - continue; - } - if n > 0 { - stdout_line(format_args!("\u{c}"))?; - } - stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; - } - if failed.is_empty() { - Ok(()) - } else { - Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) - } -} - -/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. -const MAX_RANGE: usize = 1_000_000; - -/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based -/// indices (#1923). -fn page_list(s: &str) -> Result, String> { - let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); - let mut out = Vec::new(); - for item in s.split(',').map(str::trim) { - match item.split_once('-') { - Some((from, to)) => { - let (from, to) = (number(from, item)?, number(to, item)?); - if to < from { - return Err(format!("bad page range {item:?}: it ends before it starts")); - } - if to - from >= MAX_RANGE { - return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); - } - out.extend(from - 1..to); - } - None => out.push(number(item, item)? - 1), - } - } - Ok(out) -} - -/// The options `edit` understands, for its "unknown option" message. -const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; - -/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). -/// -/// Every argument is parsed before the file is opened: an option this command does not know -/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an -/// unchanged copy and exit 0, which looks like a successful edit (#133). -fn edit(args: &[String]) -> Result<(), CliError> { - use pdfcraft_engine::{Edit, Session}; - let mut path: Option<&str> = None; - let mut out: Option<&str> = None; - let mut password: Option<&str> = None; - let mut full = false; - let mut edits = Vec::new(); - let mut i = 0; - while let Some(arg) = args.get(i).map(String::as_str) { - // Options that take no value, and the input file. - match arg { - "--full" => { - full = true; - i += 1; - continue; - } - a if a.starts_with("--") => {} - a => { - if path.is_some() { - return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); - } - path = Some(a); - i += 1; - continue; - } - } - // `edit` parses its own options (they run in order), so `--out` is checked here in the - // same way: it wrote a PDF to a file called `--full`. - let value = args.get(i + 1).map(String::as_str); - if !can_be_path_value(arg, value) { - return Err(needs_value("edit", arg, value).into()); - } - let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; - match arg { - "--out" => out = Some(value), - "--password" => password = Some(value), - "--rotate" => { - let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; - // The same rule `rotate_pages` enforces, checked here so a bad angle is named - // with its option before any file is opened. - if degrees % 90 != 0 { - return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); - } - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); - } - "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), - "--move" => { - let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let target: usize = to - .parse() - .ok() - .filter(|target| *target > 0) - .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); - } - "--insert-blank" => { - let position: usize = value - .parse() - .ok() - .filter(|position| *position > 0) - .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; - edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); - } - "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), - "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), - other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), - } - i += 2; - } - let path = path.ok_or("edit: missing file")?; - let out = out.ok_or("edit: missing --out")?; - let mut session = Session::new(); - let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; - for e in edits { - // A destination past the end must fail like `page_move` / `page_insert_blank` do, not - // silently become an append (#1330). Checked per edit: earlier edits change the count. - let n = session.get(id).map_or(0, |d| d.info.pages.len()); - let destination = match &e { - Edit::MovePages { to, .. } => Some(("--move target", *to)), - Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), - _ => None, - }; - if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { - return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); - } - // Pages count from the document as the earlier options left it, so a page past the end is - // reported with its own option and the current page count (#1550). - let listed = match &e { - Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), - Edit::DeletePages { pages } => Some(("--delete", pages)), - Edit::MovePages { pages, .. } => Some(("--move", pages)), - _ => None, - }; - if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { - return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); - } - session.apply(id, e).map_err(|e| e.to_string())?; - } - let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; - // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). - session.check_save(id, &bytes).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -/// Write a PDF a command produced, through a staging file beside the target (#2166): a write -/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. -fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { - use pdfcraft_platform::staging::{StagingName, write_atomically}; - write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) -} - -fn file_stem(path: &str) -> String { - Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) -} - -fn combine(args: &[String]) -> Result<(), CliError> { - validate_options("combine", args, COMBINE_OPTIONS)?; - let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args, COMBINE_OPTIONS); - if inputs.len() < 2 { - return Err("combine: give at least two input files".into()); - } - let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; - let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn extract(args: &[String]) -> Result<(), CliError> { - validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; - let out = flag(args, "--out").ok_or("extract: missing --out")?; - let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn split(args: &[String]) -> Result<(), CliError> { - validate_options("split", args, SPLIT_OPTIONS)?; - use pdfcraft_engine::SplitBy; - let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; - let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => { - // A count the splitter cannot hold is refused here, before the file is opened, - // rather than silently clamped to 1 further down. - let count = n - .parse::() - .ok() - .filter(|count| *count > 0) - .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; - SplitBy::PageCount(count) - } - (None, Some(list)) => SplitBy::Before(page_list(list)?), - _ => return Err("split: give either --every N or --before PAGES".into()), - }; - let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let stem = file_stem(path); - let parts = session.split(id, &by).map_err(|e| e.to_string())?; - // An output folder that does not exist yet is created (#249); one that can't be is reported - // as the folder's problem, not as the first part's. - std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; - for (a, b, bytes) in parts { - let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); - write_pdf(&name, &bytes)?; - stdout_line(format_args!("{}", name.display()))?; - } - Ok(()) -} - -/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value -/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own -/// internal message (and in `check`, passed on as text, they would turn every file into a -/// child that printed nothing), so the CLI names the option and the value instead. -fn parse_dpi(value: &str) -> Result { - value - .parse::() - .ok() - .filter(|dpi| dpi.is_finite() && *dpi > 0.0) - .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) -} - -fn render(args: &[String]) -> Result<(), CliError> { - validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; - let page: usize = - flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; - let page_index = page - 1; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; - let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; - // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. - let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { - Some("png") => Some(ImageFormat::Png), - Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), - Some("tif" | "tiff") => Some(ImageFormat::Tiff), - Some("pam") => None, - _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), - }; - // The app renders a display copy that draws comments missing their /AP streams; the CLI - // must agree with it (and with independent viewers), while keeping the file it reads intact. - let bytes = read(path)?; - let password = flag(args, "--password"); - // The renderer takes only the user password of R2–R4 files; `Session::open` and the display - // copy take either. - let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); - let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; - session.get(id).ok_or("render: the document could not be opened")?.display.clone() - } else { - pdfcraft_engine::display_bytes_for(bytes, password) - }; - let mut r = - PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); - let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = p.error { - return Err(e.into()); - } - let bytes = match format { - // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, - None => { - // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. - let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); - pam.extend_from_slice(&p.rgba); - pam - } - }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; - let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); - for warning in &p.warnings { - match warning { - pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); - } - pdfcraft_render::RenderWarning::ColourFallback => { - let _ = - writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); - } - } - } - Ok(()) -} - -/// Child-process body for `check`: prints one JSON line. -fn check_one(args: &[String]) -> Result<(), CliError> { - validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; - let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; - let start = Instant::now(); - let bytes = read(path)?; - let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { - Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), - Ok(info) => { - let mut r = PageRenderer::new(bytes, RenderConfig::default()); - let mut failed = Vec::new(); - for i in 0..info.pages.len().min(500) { - let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { - failed.push(format!("p{} text: {e}", i + 1)); - } - if let Some(e) = p.error { - failed.push(format!("p{}: {e}", i + 1)); - } - } - let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; - (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) - } - }; - // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, - // so a panic in cos shows up as a crash of this process. - if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); - } - let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, - "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); - stdout_line(format_args!("{line}"))?; - Ok(()) -} - -/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both -/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. -fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; - let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; - for num in doc.object_numbers().into_iter().take(20_000) { - if let Ok(o) = doc.try_get(num) - && let Object::Stream(s) = &*o - { - let _ = s.decoded(); - } - } - if let Some(root) = doc.root() { - let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); - } - let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; - for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] - .into_iter() - .flatten() - { - if let Ok(again) = Document::open(Arc::new(out)) { - let _ = again.root().map(|r| again.get(r)); - } - } -} - -fn short(s: &str) -> String { - s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() -} - -fn collect(paths: &[&str]) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); - let mut visited = std::collections::HashSet::new(); - while let Some(p) = stack.pop() { - if p.is_dir() { - // Directory aliases and symlink cycles must not scan the same tree repeatedly. - let identity = match p.canonicalize() { - Ok(identity) => identity, - Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); - continue; - } - }; - if !visited.insert(identity) { - continue; - } - if let Ok(rd) = std::fs::read_dir(&p) { - stack.extend(rd.flatten().map(|e| e.path())); - } - } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { - out.push(p); - } - } - out.sort(); - out -} - -fn check(args: &[String]) -> Result<(), CliError> { - validate_options("check", args, CHECK_OPTIONS)?; - // Every option is checked before the inputs are scanned, so a bad value is one message, - // not one problem per file: a zero timeout used to make every file time out, and a bad - // dpi used to reach each child, which printed nothing and was reported as a crash. - let timeout_value = flag(args, "--timeout").unwrap_or("20"); - let timeout = Duration::from_secs( - timeout_value - .parse::() - .ok() - .filter(|secs| *secs > 0) - .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, - ); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); - parse_dpi(&dpi)?; - let files = collect(&positional(args, CHECK_OPTIONS)); - if files.is_empty() { - return Err("check: no PDF files found".into()); - } - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut results = Vec::new(); - let mut counts: std::collections::BTreeMap = Default::default(); - for (n, f) in files.iter().enumerate() { - let r = run_child(&exe, f, &dpi, timeout); - let status = r["status"].as_str().unwrap_or("?").to_string(); - *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; - if status != "ok" { - eprintln!( - "[{}/{}] {status:<28} {} {}", - n + 1, - files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() - ); - } - results.push(r); - } - eprintln!("\nchecked {} files: {:?}", files.len(), counts); - if let Some(out) = flag(args, "--json") { - std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { - let file_s = file.to_string_lossy().to_string(); - let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); - let mut child = match child { - Ok(c) => c, - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - }; - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut out = String::new(); - if let Some(mut so) = child.stdout.take() { - use std::io::Read; - let _ = so.read_to_string(&mut out); - } - return match serde_json::from_str::(out.trim()) { - Ok(v) => v, - Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), - }; - } - Ok(None) if start.elapsed() > timeout => { - let _ = child.kill(); - let _ = child.wait(); - return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); - } - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - } - } -} - -// ---- automation -------------------------------------------------------------------------------- - -fn automation(args: &[String]) -> Result { - let a = pdfcraft_automation::Automation::new(); - match flag(args, "--root") { - Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), - None => Ok(a), - } -} - -fn tools() -> Result<(), CliError> { - let list: Vec = pdfcraft_automation::tools() - .iter() - .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) - .collect(); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; - Ok(()) -} - -/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is -/// written like a tool's own output, so `--root` confines it too. -fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> { - for c in content { - match c { - pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; - } - None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, - }, - } - } - Ok(()) -} - -/// What a PNG result is reported as when it isn't written to a file. -fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { - serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) -} - -/// A tool's result as JSON values, for the `--report` of a script. Same content as -/// [`print_output`], collected instead of printed. -fn output_values( - auto: &pdfcraft_automation::Automation, - content: Vec, - out: Option<&str>, -) -> Result, String> { - let mut values = Vec::new(); - for c in content { - values.push(match c { - pdfcraft_automation::Content::Json(v) => v, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) - } - None => png_summary(&data, width, height), - }, - }); - } - Ok(values) -} - -/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` -/// with the result or the message. The first failing step ends the run, as without `--report`; -/// the steps after it are listed as skipped and the exit status is the same. -fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { - let mut report = Vec::with_capacity(steps.len()); - let mut failure: Option = None; - for (i, step) in steps.iter().enumerate() { - let n = i + 1; - let tool = step["tool"].as_str(); - if failure.is_some() { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); - continue; - } - let outcome = match tool { - None => Err(format!("step {n}: missing \"tool\"")), - Some(t) => auto - .call(t, &step["args"]) - .map_err(|e| e.to_string()) - .and_then(|content| output_values(auto, content, step["out"].as_str())) - .map_err(|e| format!("step {n} ({t}): {e}")), - }; - match outcome { - Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), - Err(message) => { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); - failure = Some(message); - } - } - } - let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; - failure.map_or(Ok(()), |message| Err(CliError::Message(message))) -} - -fn run(args: &[String]) -> Result<(), CliError> { - validate_options("run", args, RUN_OPTIONS)?; - let report = args.iter().any(|a| a == "--report"); - if report && flag(args, "--script").is_none() { - return Err("run: --report needs --script".to_string().into()); - } - let mut auto = automation(args)?; - if let Some(script) = flag(args, "--script") { - let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; - let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; - if report { - return run_script_report(&mut auto, &steps); - } - for (i, step) in steps.iter().enumerate() { - let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; - let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; - print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { - CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), - stdout => stdout, - })?; - } - return Ok(()); - } - let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; - let mut obj = serde_json::Map::new(); - for kv in positional(args, RUN_OPTIONS).iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; - let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); - obj.insert(k.to_string(), value); - } - let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; - print_output(&auto, content, flag(args, "--out")) -} - -/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, -/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments -/// that are left. A malformed root flag is an error, never a root silently left out. -#[cfg(feature = "mcp")] -fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { - let mut found = automation_args::AutomationArgs::new(None, None); - let mut left = Vec::new(); - let mut it = args.iter().cloned(); - while let Some(arg) = it.next() { - if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { - left.push(arg); - } - } - let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) - .map_err(|e| format!("{command}: {e}"))?; - Ok((roots, left)) -} - -#[cfg(feature = "mcp")] -fn mcp(args: &[String]) -> Result<(), CliError> { - let (roots, args) = automation_roots("mcp", args)?; - let args = args.as_slice(); - validate_options("mcp", args, MCP_OPTIONS)?; - let compact = args.iter().any(|a| a == "--compact"); - let mut auto = automation(args)?; - if let Some(roots) = roots { - let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: files confined: read root {}, write root {}", - show(roots.read_root()), - show(roots.write_root()) - ); - auto = auto.with_automation_roots(Some(roots)); - } - let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", - pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], - if compact { ", compact tool list" } else { "" } - ); - server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) -} - -// ---- UI control channel client ----------------------------------------------------------------- - -/// One request to a running app's control channel (`pdfcraft --control FILE`). -fn ui(args: &[String]) -> Result<(), CliError> { - validate_options("ui", args, UI_OPTIONS)?; - use std::io::{BufRead, BufReader, Write as _}; - let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; - let info = read_control_file(file)?; - let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; - // Refused rather than truncated, which would quietly reach some other port. - let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; - let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args, UI_OPTIONS); - let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; - let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; - let mut params = serde_json::Map::new(); - for kv in pos.iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; - params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); - } - let stream = - std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; - stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; - let mut write = stream.try_clone().map_err(|e| e.to_string())?; - let mut lines = BufReader::new(stream).lines(); - let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { - writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; - let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; - let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; - match reply.get("error") { - Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), - None => Ok(reply["result"].clone()), - } - }; - rpc(1, "auth", serde_json::json!({ "token": token }))?; - let mut result = rpc(2, &method, serde_json::Value::Object(params))?; - if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { - use base64::Engine as _; - let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; - std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; - result["png_base64"] = serde_json::json!(format!("written to {out}")); - } - stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; - Ok(()) -} - -/// Most a control file may hold. The app writes under 100 bytes. -const CONTROL_FILE_MAX: u64 = 64 * 1024; - -const CONTROL_FILE_ADVICE: &str = - "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; - -/// Read the file written by `pdfcraft --control FILE`. -/// -/// It names the port that receives the token and every command, typed text included, so it must -/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file -/// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). -fn read_control_file(file: &str) -> Result { - use std::io::Read as _; - let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; - if at_path.file_type().is_symlink() { - return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); - } - if !at_path.is_file() { - return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); - } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); - } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { - return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); - } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; - if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { - return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); - } - serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) -} - -/// Why user `me` can't trust a control file with this owner and mode, if they can't. -#[cfg(any(test, unix))] -fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { - if owner != me { - return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); - } - let permissions = mode & 0o7777; - (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) -} - -/// The effective user id: the owner of the files this process creates, and so of a control file -/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is -/// safe and can't fail. -#[cfg(unix)] -fn current_uid() -> u32 { - rustix::process::geteuid().as_raw() -} - -#[cfg(test)] -mod tests { - use super::control_file_problem; - - #[test] - fn a_control_file_must_be_yours_and_private() { - // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. - assert_eq!(control_file_problem(1000, 0o100600, 1000), None); - assert_eq!(control_file_problem(1000, 0o600, 1000), None); - assert_eq!(control_file_problem(1000, 0o400, 1000), None); - let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); - assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); - // Root gets no exception: a file a user planted is still theirs. - assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); - for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { - let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); - assert!(open.contains("other users"), "{mode:o}: {open}"); - } - assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); - } -} diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs new file mode 100644 index 000000000..ab1a3db34 --- /dev/null +++ b/apps/pdfcraft-cli/src/options.rs @@ -0,0 +1,148 @@ +use super::*; + +pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +#[derive(Clone, Copy)] +pub(crate) struct OptionSpec { + pub(crate) name: &'static str, + pub(crate) takes_value: bool, +} + +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + +/// Refuse unknown options and malformed confinement flags before any file is read. +pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + if specs.iter().any(|spec| spec.name == "--root") { + // `flag` searches every token, including another option's value. Match that lookup so + // a malformed confinement flag cannot be hidden there and fall back to no root. + let mut root_seen = false; + for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { + if root_seen { + return Err(format!("{command}: --root may only be supplied once").into()); + } + root_seen = true; + if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { + return Err(format!("{command}: --root needs a directory value").into()); + } + } + } + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}").into()); + }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; + } else { + i += 1; + } + } + Ok(()) +} + +pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const TEXT_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; + +pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--every", takes_value: true }, + OptionSpec { name: "--before", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; + +pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--timeout", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--json", takes_value: true }, +]; + +pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--root", takes_value: true }, + OptionSpec { name: "--script", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--report", takes_value: false }, +]; + +pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; + +#[cfg(feature = "mcp")] +pub(crate) const MCP_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; + +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } + } + } + out +} diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs new file mode 100644 index 000000000..19eed2187 --- /dev/null +++ b/apps/pdfcraft-cli/src/run.rs @@ -0,0 +1,186 @@ +#[cfg(feature = "mcp")] +use super::options::MCP_OPTIONS; +use super::options::{RUN_OPTIONS, flag, positional, validate_options}; +use super::*; + +// ---- automation -------------------------------------------------------------------------------- + +pub(crate) fn automation(args: &[String]) -> Result { + let a = pdfcraft_automation::Automation::new(); + match flag(args, "--root") { + Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), + None => Ok(a), + } +} + +pub(crate) fn tools() -> Result<(), CliError> { + let list: Vec = pdfcraft_automation::tools() + .iter() + .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) + .collect(); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; + Ok(()) +} + +/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is +/// written like a tool's own output, so `--root` confines it too. +pub(crate) fn print_output( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result<(), CliError> { + for c in content { + match c { + pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; + } + None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, + }, + } + } + Ok(()) +} + +/// What a PNG result is reported as when it isn't written to a file. +fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) +} + +/// A tool's result as JSON values, for the `--report` of a script. Same content as +/// [`print_output`], collected instead of printed. +fn output_values( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result, String> { + let mut values = Vec::new(); + for c in content { + values.push(match c { + pdfcraft_automation::Content::Json(v) => v, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) + } + None => png_summary(&data, width, height), + }, + }); + } + Ok(values) +} + +/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` +/// with the result or the message. The first failing step ends the run, as without `--report`; +/// the steps after it are listed as skipped and the exit status is the same. +fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { + let mut report = Vec::with_capacity(steps.len()); + let mut failure: Option = None; + for (i, step) in steps.iter().enumerate() { + let n = i + 1; + let tool = step["tool"].as_str(); + if failure.is_some() { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); + continue; + } + let outcome = match tool { + None => Err(format!("step {n}: missing \"tool\"")), + Some(t) => auto + .call(t, &step["args"]) + .map_err(|e| e.to_string()) + .and_then(|content| output_values(auto, content, step["out"].as_str())) + .map_err(|e| format!("step {n} ({t}): {e}")), + }; + match outcome { + Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), + Err(message) => { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); + failure = Some(message); + } + } + } + let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; + failure.map_or(Ok(()), |message| Err(CliError::Message(message))) +} + +pub(crate) fn run(args: &[String]) -> Result<(), CliError> { + validate_options("run", args, RUN_OPTIONS)?; + let report = args.iter().any(|a| a == "--report"); + if report && flag(args, "--script").is_none() { + return Err("run: --report needs --script".to_string().into()); + } + let mut auto = automation(args)?; + if let Some(script) = flag(args, "--script") { + let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; + let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + if report { + return run_script_report(&mut auto, &steps); + } + for (i, step) in steps.iter().enumerate() { + let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; + let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; + print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { + CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), + stdout => stdout, + })?; + } + return Ok(()); + } + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let mut obj = serde_json::Map::new(); + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; + let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); + obj.insert(k.to_string(), value); + } + let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; + print_output(&auto, content, flag(args, "--out")) +} + +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +#[cfg(feature = "mcp")] +fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + +#[cfg(feature = "mcp")] +pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> { + let (roots, args) = automation_roots("mcp", args)?; + let args = args.as_slice(); + validate_options("mcp", args, MCP_OPTIONS)?; + let compact = args.iter().any(|a| a == "--compact"); + let mut auto = automation(args)?; + if let Some(roots) = roots { + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: files confined: read root {}, write root {}", + show(roots.read_root()), + show(roots.write_root()) + ); + auto = auto.with_automation_roots(Some(roots)); + } + let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", + pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], + if compact { ", compact tool list" } else { "" } + ); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) +} diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs new file mode 100644 index 000000000..9d1223352 --- /dev/null +++ b/apps/pdfcraft-cli/src/ui.rs @@ -0,0 +1,131 @@ +use super::options::{UI_OPTIONS, flag, positional, validate_options}; +use super::*; + +// ---- UI control channel client ----------------------------------------------------------------- + +/// One request to a running app's control channel (`pdfcraft --control FILE`). +pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { + validate_options("ui", args, UI_OPTIONS)?; + use std::io::{BufRead, BufReader, Write as _}; + let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; + let info = read_control_file(file)?; + let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; + // Refused rather than truncated, which would quietly reach some other port. + let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; + let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; + let pos = positional(args, UI_OPTIONS); + let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; + let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; + let mut params = serde_json::Map::new(); + for kv in pos.iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; + params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); + } + let stream = + std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; + stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; + let mut write = stream.try_clone().map_err(|e| e.to_string())?; + let mut lines = BufReader::new(stream).lines(); + let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { + writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; + let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; + let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + match reply.get("error") { + Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), + None => Ok(reply["result"].clone()), + } + }; + rpc(1, "auth", serde_json::json!({ "token": token }))?; + let mut result = rpc(2, &method, serde_json::Value::Object(params))?; + if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; + std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; + result["png_base64"] = serde_json::json!(format!("written to {out}")); + } + stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; + Ok(()) +} + +/// Most a control file may hold. The app writes under 100 bytes. +const CONTROL_FILE_MAX: u64 = 64 * 1024; + +const CONTROL_FILE_ADVICE: &str = + "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; + +/// Read the file written by `pdfcraft --control FILE`. +/// +/// It names the port that receives the token and every command, typed text included, so it must +/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file +/// first; the app then can't write it, and the CLI would talk to the other user's listener. +/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone +/// else or open to other users (the app writes it with mode 600). +fn read_control_file(file: &str) -> Result { + use std::io::Read as _; + let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; + if at_path.file_type().is_symlink() { + return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); + } + if !at_path.is_file() { + return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); + } + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } + } + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { + return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); + } + serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) +} + +/// Why user `me` can't trust a control file with this owner and mode, if they can't. +#[cfg(any(test, unix))] +fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { + if owner != me { + return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); + } + let permissions = mode & 0o7777; + (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) +} + +/// The effective user id: the owner of the files this process creates, and so of a control file +/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is +/// safe and can't fail. +#[cfg(unix)] +fn current_uid() -> u32 { + rustix::process::geteuid().as_raw() +} + +#[cfg(test)] +mod tests { + use super::control_file_problem; + + #[test] + fn a_control_file_must_be_yours_and_private() { + // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. + assert_eq!(control_file_problem(1000, 0o100600, 1000), None); + assert_eq!(control_file_problem(1000, 0o600, 1000), None); + assert_eq!(control_file_problem(1000, 0o400, 1000), None); + let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); + assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); + // Root gets no exception: a file a user planted is still theirs. + assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); + for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { + let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); + assert!(open.contains("other users"), "{mode:o}: {open}"); + } + assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); + } +}