mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 03:49:34 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5801bb93c | ||
|
|
867a89e112 | ||
|
|
bf990f8b6a |
@@ -85,20 +85,14 @@ impl AutomationRoots {
|
|||||||
|
|
||||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||||
self.check_path(Path::new(path), access)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
|
||||||
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
|
||||||
let path_text = path.display();
|
|
||||||
let roots = match access {
|
let roots = match access {
|
||||||
Access::Read => &self.read,
|
Access::Read => &self.read,
|
||||||
Access::Write => &self.write,
|
Access::Write => &self.write,
|
||||||
};
|
};
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return match access {
|
return match access {
|
||||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let resolved = resolve(path)?;
|
let resolved = resolve(path)?;
|
||||||
@@ -107,8 +101,8 @@ impl AutomationRoots {
|
|||||||
}
|
}
|
||||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||||
match access {
|
match access {
|
||||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
||||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,23 +191,17 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
|||||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
|
||||||
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
|
||||||
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||||
/// existing folder's links followed and the rest appended.
|
/// existing folder's links followed and the rest appended.
|
||||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||||
if path.as_os_str().is_empty() {
|
if path.is_empty() {
|
||||||
return Err("automation path rejected: path is empty".into());
|
return Err("automation path rejected: path is empty".into());
|
||||||
}
|
}
|
||||||
let shown = path.display();
|
|
||||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||||
}
|
}
|
||||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||||
// The names below the nearest existing folder, last first.
|
// The names below the nearest existing folder, last first.
|
||||||
let mut missing: Vec<OsString> = vec![];
|
let mut missing: Vec<OsString> = vec![];
|
||||||
let mut at = absolute.as_path();
|
let mut at = absolute.as_path();
|
||||||
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
|||||||
return Ok(found);
|
return Ok(found);
|
||||||
}
|
}
|
||||||
Err(e) => match std::fs::symlink_metadata(at) {
|
Err(e) => match std::fs::symlink_metadata(at) {
|
||||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let parent = at.parent();
|
let parent = at.parent();
|
||||||
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
|||||||
at = parent;
|
at = parent;
|
||||||
}
|
}
|
||||||
(Some(Component::ParentDir), _) => {
|
(Some(Component::ParentDir), _) => {
|
||||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||||
}
|
}
|
||||||
// Down to the root (or drive) and it doesn't exist either.
|
// Down to the root (or drive) and it doesn't exist either.
|
||||||
_ => {
|
_ => {
|
||||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,8 +668,6 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||||
// A confined automation client may write only inside its write roots, this file included.
|
|
||||||
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
|
||||||
if let Some(d) = path.parent() {
|
if let Some(d) = path.parent() {
|
||||||
std::fs::create_dir_all(d)?;
|
std::fs::create_dir_all(d)?;
|
||||||
}
|
}
|
||||||
@@ -955,20 +953,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
|||||||
assert_eq!(w, vec!["check", "done"]);
|
assert_eq!(w, vec!["check", "done"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
|
|
||||||
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
|
||||||
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
|
|
||||||
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
|
|
||||||
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
|
|
||||||
set_user_dictionary_path(outside.join("user.dic"));
|
|
||||||
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
|
|
||||||
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
|
|
||||||
assert!(!outside.exists(), "the refused write created nothing");
|
|
||||||
let _ = std::fs::remove_dir_all(&root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn long_and_numeric_flags_and_latin1() {
|
fn long_and_numeric_flags_and_latin1() {
|
||||||
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const INVALID_REQUEST: i64 = -32600;
|
|||||||
const METHOD_NOT_FOUND: i64 = -32601;
|
const METHOD_NOT_FOUND: i64 = -32601;
|
||||||
const INVALID_PARAMS: i64 = -32602;
|
const INVALID_PARAMS: i64 = -32602;
|
||||||
const INTERNAL_ERROR: i64 = -32603;
|
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;
|
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||||
/// Keep request frames in step with the connected backend's reply limit.
|
/// Keep request frames in step with the connected backend's reply limit.
|
||||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
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.
|
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
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 {
|
} 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))
|
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-9
@@ -357,18 +357,30 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
|||||||
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
||||||
}
|
}
|
||||||
"batch" => {
|
"batch" => {
|
||||||
|
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
|
||||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
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![];
|
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("");
|
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||||
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||||
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
|
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
|
||||||
Err(e) => {
|
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
|
||||||
out.push(json!({"command": id, "ok": false, "error": e}));
|
};
|
||||||
if !keep {
|
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
|
||||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
// Failures count too: with keepGoing, a remote backend's large errors add up.
|
||||||
}
|
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);
|
||||||
|
if failed && !keep {
|
||||||
|
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolResult::json(&Value::Array(out))
|
ToolResult::json(&Value::Array(out))
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//! 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(¬es.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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_batch_tool_charges_failures_too() {
|
||||||
|
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
|
||||||
|
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||||
|
let id = "x".repeat(1 << 20);
|
||||||
|
let commands = vec![json!({"command": id}); 70];
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
// 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)]
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
mod automation_roots;
|
mod automation_roots;
|
||||||
|
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
mod batch_budget;
|
||||||
|
|||||||
Reference in New Issue
Block a user