Compare commits

..
7 changed files with 121 additions and 81 deletions
+27 -1
View File
@@ -16,6 +16,12 @@ const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
/// much of the document (an inspection holds every note), so a batch holds at most this much.
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
const RESOURCE_NOT_FOUND: i64 = -32002;
/// Keep request frames in step with the connected backend's reply limit.
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
@@ -93,8 +99,28 @@ impl Server {
// Batches were removed in 2025-06-18; still answer older clients sensibly.
if batch.is_empty() {
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
} else if batch.len() > MAX_BATCH_ITEMS {
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
} else {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
let mut used = 0usize;
let mut replies: Vec<Value> = Vec::new();
for m in batch {
if used > MAX_BATCH_BYTES {
// Not run: answer each remaining request with a short error.
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
}
continue;
}
if let Some(r) = self.handle(m) {
used = used.saturating_add(r.to_string().len());
replies.push(if used > MAX_BATCH_BYTES {
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
} else {
r
});
}
}
(!replies.is_empty()).then_some(Value::Array(replies))
}
}
+17 -2
View File
@@ -357,12 +357,27 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
}
"batch" => {
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
if commands.len() > MAX_BATCH_ITEMS {
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
}
let mut out = vec![];
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
let mut used = 0usize;
for c in commands {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
Ok(v) => {
let entry = json!({"command": id, "ok": true, "result": v});
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
used = used.saturating_add(json!(entry.to_string()).to_string().len());
if used > MAX_BATCH_BYTES {
out.push(json!({"command": id, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
}
out.push(entry);
}
Err(e) => {
out.push(json!({"command": id, "ok": false, "error": e}));
if !keep {
+58
View File
@@ -0,0 +1,58 @@
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
use deckcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
fn server_with_notes(bytes: usize) -> Server {
let mut server = Server::new(Box::new(Headless::with_document()));
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
assert_ne!(reply["result"]["isError"], true, "{reply}");
server
}
#[test]
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
let mut server = server_with_notes(1 << 20);
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
let too_many: Vec<Value> = (0..1001).map(ping).collect();
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
assert_eq!(reply["error"]["code"], -32600, "{reply}");
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
let line = Value::Array((0..100).map(inspect).collect()).to_string();
let reply = server.handle_line(&line).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
assert_eq!(replies.len(), 100, "every request is answered");
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
}
#[test]
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
let mut server = server_with_notes(1 << 20);
let call = |server: &mut Server, commands: Vec<Value>| {
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
};
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
assert!(err && text.contains("at most 1000"), "{text}");
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
assert!(text.len() < (64 << 20) + (4 << 20));
}
#[test]
fn the_batch_tool_charges_the_escaped_reply() {
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
let mut server = Server::new(Box::new(Headless::with_document()));
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
server.handle_line(&notes.to_string()).unwrap();
let commands = vec![json!({"command": "slide.inspect"}); 30];
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
let reply = server.handle_line(&line.to_string()).unwrap();
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
assert!(reply.contains("64 MiB"));
}
+2
View File
@@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod batch_budget;
+17 -20
View File
@@ -663,55 +663,52 @@ fn read_slides(
let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
// A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
slide.notes = notes;
}
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
read_slide_comments(pkg, pres_part, &part, &mut slide);
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
file_slide_ids
}
/// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
/// The slide's notes text, read once per notes part.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?;
if !owned.insert(opc::norm(&n.target)) {
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return None;
let key = opc::norm(&n.target);
if let Some(cached) = cache.get(&key) {
return Some(cached.clone());
}
let pkg = imp.pkg;
let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd);
Some(read_notes(imp, &np, &nd.root))
let notes = read_notes(imp, &np, &nd.root);
cache.insert(key, notes.clone());
Some(notes)
}
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped.
/// Each comments part is read once, for the first slide that names it.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
let mut parts: Vec<(Doc, bool)> = vec![];
for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern, fresh));
parts.push((cd, modern));
}
}
let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern, fresh) in parts {
if !fresh || (has_modern && !modern) {
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
continue;
}
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
-27
View File
@@ -146,33 +146,6 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
-31
View File
@@ -287,34 +287,3 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}