mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 18:31:10 -06:00
fix(automation): guard MCP dispatch and bound line and file reads (#3924)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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)")?;
|
||||
|
||||
@@ -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() })?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) })?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms)) } else { self.dispatch(method, ¶ms) };
|
||||
let result = guarded_dispatch(method, || self.dispatch(method, ¶ms));
|
||||
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.");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user