fix(automation): guard MCP dispatch and bound line and file reads (#3924)

This commit is contained in:
jonathanwxh-cell
2026-10-22 15:08:56 +08:00
parent 9a0ec64568
commit b6b67a86d8
8 changed files with 225 additions and 34 deletions
+5 -5
View File
@@ -607,9 +607,9 @@ impl Automation {
for p in paths {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
let result =
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| {
pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true)
});
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
);
out.push(match result {
Ok(r) => {
let target = child(&folder, &name);
@@ -696,7 +696,7 @@ impl Automation {
let mut files = Vec::new();
for p in a.strs("paths")? {
let path = self.resolve(p, false)?;
files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?));
files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?));
}
let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?;
let target = self.resolve(a.str("path")?, true)?;
@@ -841,7 +841,7 @@ impl Automation {
let result = self
.resolve(p, false)
.map_err(|e| e.to_string())
.and_then(|src| std::fs::read(&src).map_err(|e| e.to_string()))
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
out.push(match result {
Ok(r) => {
+2 -2
View File
@@ -385,7 +385,7 @@ impl Automation {
"attachment" => {
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
let path = self.resolve(a.str("path")?, false)?;
let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let data = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let icon = match a.opt_str("icon")? {
Some(n) => pdfcraft_engine::AttachIcon::from_name(n)
@@ -679,7 +679,7 @@ impl Automation {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let [x, y] = a.need::<2>("at", "a stamp (its centre)")?;
+2 -2
View File
@@ -118,7 +118,7 @@ impl Automation {
let page = self.page(a)?;
let ph = self.doc(a)?.info.pages[page].height as f64;
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph));
let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?;
@@ -188,7 +188,7 @@ impl Automation {
}
if let Some(p) = a.opt_str("image")? {
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
// Box and transforms first, then the new picture.
self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?;
+4 -2
View File
@@ -345,7 +345,8 @@ mod tests {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"];
const RESOLVED_BY_CALLER: &[&str] =
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = vec![];
for entry in std::fs::read_dir(&dir).unwrap() {
@@ -365,7 +366,8 @@ mod tests {
|| !(trimmed.contains("std::fs")
|| trimmed.contains("File::open")
|| trimmed.contains("File::create")
|| trimmed.contains("OpenOptions"))
|| trimmed.contains("OpenOptions")
|| trimmed.contains("read_input"))
{
continue;
}
+1 -1
View File
@@ -684,7 +684,7 @@ impl Automation {
pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count());
let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?;
+64 -11
View File
@@ -394,7 +394,7 @@ impl Automation {
"page_replace" => {
let pages = self.pages(&a, "pages")?;
let path = self.resolve(a.str("path")?, false)?;
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
let bytes = Arc::new(read_input(&path)?);
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let src_pages = match a.opt_ints("from_pages")? {
Some(p) => one_based(&p)?,
@@ -543,7 +543,7 @@ impl Automation {
"delete" => ImageEdit::Delete,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
ImageEdit::Replace {
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
bytes: Arc::new(bytes),
@@ -766,7 +766,7 @@ impl Automation {
"form_fill" => self.form_fill(&a)?,
"form_set_image" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
}
"form_reset" => self.form_reset(&a)?,
@@ -1000,7 +1000,7 @@ impl Automation {
fn doc_open(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
@@ -1270,7 +1270,7 @@ impl Automation {
fn mark_file(&self, a: &Args) -> Result<Option<pdfcraft_engine::MarkFile>> {
let Some(p) = a.opt_str("file")? else { return Ok(None) };
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1;
Ok(Some(pdfcraft_engine::MarkFile {
name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
@@ -1444,7 +1444,7 @@ impl Automation {
let mut images = Vec::new();
for p in a.strs("paths")? {
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes));
}
let name = if images.len() == 1 {
@@ -1459,7 +1459,7 @@ impl Automation {
(Some(t), _) => ("Text".to_string(), t.to_string()),
(None, Some(p)) => {
let path = self.resolve(p, false)?;
let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let t = read_input(&path)?;
(path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t))
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
@@ -1634,7 +1634,7 @@ impl Automation {
fn insert_file(&mut self, a: &Args) -> Result<Value> {
let at = self.position(a, "at")?;
let path = self.resolve(a.str("path")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename })
@@ -1677,7 +1677,7 @@ impl Automation {
// Each file is read, converted and copied in turn, so a long list holds one file at a time.
let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result<pdfcraft_engine::CombineSource, ToolError> {
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
@@ -1694,7 +1694,7 @@ impl Automation {
let mut out = Vec::new();
for &p in paths {
let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| {
let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?;
let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?;
let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?;
@@ -1798,7 +1798,7 @@ impl Automation {
if let Some(g) = group {
firsts.push((g, path.clone(), sources.len()));
}
let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?);
let bytes = Arc::new(read_input(&path)?);
// Images and text files are made into PDFs first (#569), as in the Combine files tab.
let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
self.session.as_pdf(&file_name, &bytes).map_err(failed)?
@@ -2700,6 +2700,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf {
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
}
/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`).
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap.
pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result<Vec<u8>> {
use std::io::Read as _;
let file = std::fs::File::open(path)?;
if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
}
let mut bytes = Vec::new();
file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_INPUT {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB"));
}
Ok(bytes)
}
pub(crate) fn read_input(path: &Path) -> Result<Vec<u8>> {
read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display())))
}
/// Write and sync a temporary file in the same directory, then rename over the target.
/// This flushes staged contents; it does not promise parent-directory durability after a
/// power loss. Filesystems that refuse sync cause the save to fail before publication.
@@ -3257,4 +3279,35 @@ mod tests {
assert!(a.session.get(id).is_none());
}
}
#[test]
fn tool_file_reads_refuse_inputs_exceeding_512_mib() {
let dir = staging_dir("capped-read");
let huge = dir.join("huge.bin");
let file = std::fs::File::create(&huge).unwrap();
file.set_len(MAX_INPUT + 1).unwrap();
drop(file);
let huge_str = huge.to_string_lossy().into_owned();
let mut a = Automation::new();
let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0;
for (tool, args) in [
("doc_open", json!({ "path": huge_str })),
("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })),
("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })),
("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })),
("doc_import_data", json!({ "doc": id, "path": huge_str })),
("doc_create", json!({ "from": "text", "path": huge_str })),
("doc_create", json!({ "from": "images", "paths": [huge_str] })),
("doc_combine", json!({ "paths": [huge_str, huge_str] })),
("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })),
("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })),
("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })),
("sign_trust", json!({ "paths": [huge_str] })),
] {
let err = a.call(tool, &args).unwrap_err().to_string();
assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}
+145 -9
View File
@@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB).
pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \
(doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \
@@ -83,18 +87,36 @@ impl McpServer {
}
/// Serve until `input` reaches end of file.
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
let mut buf = Vec::new();
loop {
match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? {
LineRead::End => return Ok(()),
LineRead::TooLong => {
drain_line(&mut input)?;
buf.clear();
buf.shrink_to(64 * 1024);
let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes"));
writeln!(output, "{reply}")?;
output.flush()?;
continue;
}
LineRead::Line => {}
}
if let Some(reply) = self.handle_line(&line) {
let reply = match std::str::from_utf8(&buf) {
Ok(line) if line.trim().is_empty() => None,
Ok(line) => self.handle_line(line),
Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()),
};
if buf.capacity() > 64 * 1024 {
buf.clear();
buf.shrink_to(64 * 1024);
}
if let Some(reply) = reply {
writeln!(output, "{reply}")?;
output.flush()?;
}
}
Ok(())
}
/// Handle one JSON-RPC message; returns the serialized reply, if one is due.
@@ -116,7 +138,7 @@ impl McpServer {
};
let params = obj.get("params").cloned().unwrap_or(Value::Null);
let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer
let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, &params)) } else { self.dispatch(method, &params) };
let result = guarded_dispatch(method, || self.dispatch(method, &params));
Some(match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => error(id, code, &message),
@@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> {
Ok((name.to_owned(), inner))
}
/// What [`read_line_limited`] found.
#[derive(Debug, PartialEq, Eq)]
enum LineRead {
/// A line, now in the buffer (newline and a trailing `\r` stripped).
Line,
/// End of stream.
End,
/// The line is longer than the limit; at most `max + 1` bytes of it were read.
TooLong,
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf`.
fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> std::io::Result<LineRead> {
use std::io::Read as _;
buf.clear();
// `take` bounds what is buffered for one line: never more than `max + 1` bytes.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
if reader.take(limit).read_until(b'\n', buf)? == 0 {
return Ok(LineRead::End);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(LineRead::Line);
}
// No newline: either the limit was passed, or this is a last line before end of stream.
Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line })
}
/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it.
fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> {
loop {
let chunk = reader.fill_buf()?;
if chunk.is_empty() {
return Ok(());
}
if let Some(pos) = chunk.iter().position(|&b| b == b'\n') {
reader.consume(pos + 1);
return Ok(());
}
let len = chunk.len();
reader.consume(len);
}
}
/// Last-resort boundary shared by every tools/call route, including compact dispatch.
fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}"))))
}
/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result
/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error.
fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
if method == "tools/call" {
return guarded_tool(f);
}
pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}"))))
}
fn tool_content(name: &str, content: Vec<Content>) -> Value {
let failed = matches!(name, "command_batch" | "doc_batch")
&& content.iter().any(|c| match c {
@@ -509,7 +587,12 @@ fn call_result(content: Vec<Content>) -> Value {
#[cfg(test)]
mod tests {
use super::{Resource, first_sentence, parse_uri};
use std::io::{BufReader, Read as _};
use serde_json::{Value, json};
use super::{McpServer, Resource, first_sentence, parse_uri};
use crate::Automation;
#[test]
fn conventions_panic_is_a_tool_error() {
@@ -520,6 +603,59 @@ mod tests {
assert_eq!(next["ok"], true);
}
#[test]
fn guarded_dispatch_catches_resource_and_protocol_panics() {
for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] {
let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err();
assert_eq!(code, -32603, "{method}");
assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}");
}
let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap();
assert_eq!(ok["resources"], json!([]));
}
#[test]
fn read_line_limited_and_drain_line_respect_boundaries() {
use super::{LineRead, drain_line, read_line_limited};
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End);
let mut overlong: &[u8] = b"0123456789abcdef\nafter\n";
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong);
assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered");
drain_line(&mut overlong).unwrap();
assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line);
assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly");
}
#[test]
fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() {
let init = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }
})
.to_string();
let tail = format!("\n{init}\n");
let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes());
let mut out = Vec::new();
McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 3, "{replies:?}");
assert_eq!(replies[0]["error"]["code"], -32700);
assert_eq!(replies[1]["error"]["code"], -32600);
assert_eq!(replies[2]["id"], 1);
assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft");
}
#[test]
fn first_sentence_skips_abbreviations() {
assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages.");
+2 -2
View File
@@ -189,7 +189,7 @@ impl Automation {
return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows")));
}
let path = self.resolve(a.str("id")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e {
sign::SignError::WrongPassword => bad("the digital ID password is incorrect"),
e => failed(e),
@@ -320,7 +320,7 @@ impl Automation {
};
for p in paths {
let path = self.resolve(&p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let bytes = crate::read_input(&path)?;
let lower = p.to_ascii_lowercase();
let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") {
let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?;