Files
photocraft/crates/automation/src/rpc.rs
T
A Hazy Reign Is FluidandBrandon Thomas 6a36c48670 fix(automation): release idle headless TCP server ports (#3914)
* fix(automation): release idle headless TCP server ports

* fix(automation): retry aborted incoming TCP connections

* test(automation): synchronize idle daemon client acceptance

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-23 18:36:14 -04:00

1287 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless JSON-lines server: the control protocol's envelope
//! (`{"id","method","params"}` → `{"id","ok","result"|"error"}`) over a
//! [`Headless`] session, so scripts and agents can keep one editing session
//! open without MCP or the GUI. `photocraft-cli serve` runs it on stdio or a
//! loopback TCP port.
//!
//! Methods (camelCase params):
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.place {path, scale?, fit?, center?, index?}` (#2810: the read root is the scope,
//! the counterpart of the refused ambient `file.placeEmbedded`),
//! `doc.exportLayers {dir, format?, prefix?, visibleOnly?, quality?, index?}`,
//! `doc.exportDataSets {dir, format?, dataSets?, naming?, index?}`,
//! `doc.importDataSets {path, delimiter?, index?}` (#3216: the same treatment for the export
//! and CSV half of #2810), `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
//! - `methods`: this list.
use std::io::{BufRead, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use base64::Engine as _;
use serde_json::{Value, json};
use crate::budgets::{BatchReplyBudget, write_reply};
use crate::security::{
AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply,
configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply,
};
use crate::{AutomationError, Headless};
/// Method names served by [`Headless::handle`].
pub const METHODS: &[&str] = &[
"engine.execute",
"jobs.list",
"jobs.cancel",
"engine.commands",
"session.list",
"doc.open",
"doc.place",
"doc.new",
"doc.save",
"doc.exportLayers",
"doc.exportDataSets",
"doc.importDataSets",
"doc.inspect",
"doc.render",
"doc.select",
"doc.close",
"batch",
"methods",
];
fn bad(msg: impl Into<String>) -> AutomationError {
AutomationError::BadRequest(msg.into())
}
/// The optional `index` param: missing or null means the active document; anything else must be a
/// non-negative integer (never silently fall back to the active document, which `doc.close` would close).
fn index_of(p: &Value) -> Result<Option<usize>, AutomationError> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")),
}
}
fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
p.get(k).and_then(Value::as_str)
}
impl Headless {
/// Dispatch one request. Unknown methods and bad params are errors, never panics.
pub fn handle(&mut self, method: &str, params: Value) -> Result<Value, AutomationError> {
self.sync_jobs();
let p = if params.is_null() { json!({}) } else { params };
match method {
"engine.execute" => {
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
}
// Background jobs (#210): list (applying any that finished) and cancel.
"jobs.list" => self.command_start("jobs.list", json!({}), true),
"jobs.cancel" => self.command_start("jobs.cancel", p, true),
"engine.commands" => {
let all = self.command_list();
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
None => all,
Some(n) => {
Value::Array(all.as_array().into_iter().flatten().filter(|c| photocraft_engine::commands::list_entry_matches(c, &n)).cloned().collect())
}
})
}
"session.list" => Ok(self.session_list()),
"doc.open" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.open needs `path`"))?;
self.open(&PathBuf::from(path))
}
"doc.place" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.place needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.place(index_of(&p)?, path, &Value::Object(opts))
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() };
opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?;
opts.encode.dds = photocraft_engine::file_cmds::dds_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?.unwrap_or_default();
match p.get("quality") {
None | Some(Value::Null) => {}
Some(v) => {
// Like the CLI's `--quality`: never ignore or clamp a bad value.
let q = v
.as_u64()
.filter(|q| (1..=100).contains(q))
.and_then(|q| u8::try_from(q).ok())
.ok_or_else(|| bad(format!("`quality` must be a whole number from 1 to 100, got {v}")))?;
opts.encode.jpeg_quality = q;
opts.encode.webp_quality = q;
opts.encode.webp_lossless = false;
}
}
let path = str_of(&p, "path").map(PathBuf::from);
self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts)
}
"doc.exportLayers" | "doc.exportDataSets" => {
let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("dir");
opts.remove("index");
let opts = Value::Object(opts);
if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) }
}
"doc.importDataSets" => {
let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?;
let mut opts = p.as_object().cloned().unwrap_or_default();
opts.remove("path");
opts.remove("index");
self.import_data_sets(index_of(&p)?, path, &Value::Object(opts))
}
"doc.inspect" => self.inspect(index_of(&p)?),
"doc.render" => {
let max = match p.get("maxSide") {
None => 1024,
Some(value) => {
value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))?
}
};
let png = self.render_png(index_of(&p)?, max)?;
match str_of(&p, "path") {
Some(path) => {
self.write_render(&PathBuf::from(path), &png)?;
Ok(json!({"path": path, "bytes": png.len()}))
}
None => Ok(json!({
"mime": "image/png",
"base64": base64::engine::general_purpose::STANDARD.encode(&png),
})),
}
}
"doc.select" => {
let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?;
self.select(i)
}
"doc.close" => self.close(index_of(&p)?),
"batch" => self.batch(&p),
"methods" => Ok(json!(METHODS)),
other => Err(bad(format!("unknown method `{other}` (try `methods`)"))),
}
}
/// Run `steps` in order. Each step is `{command, params?, wait?}` (an engine command) or
/// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless
/// `stopOnError` is false; the reply lists every step's result.
pub fn batch(&mut self, p: &Value) -> Result<Value, AutomationError> {
self.batch_with_budget(p, BatchReplyBudget::default())
}
/// [`Headless::batch`] with a caller's reply budget (MCP charges escaped sizes, see
/// [`BatchReplyBudget::escaped`]). Steps stop once the budget runs out.
pub fn batch_with_budget(&mut self, p: &Value, mut reply_budget: BatchReplyBudget) -> Result<Value, AutomationError> {
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?;
if steps.len() > MAX_BATCH_STEPS {
return Err(bad(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", steps.len())));
}
let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true);
let mut results = Vec::with_capacity(steps.len());
let mut failed = 0usize;
for (i, s) in steps.iter().enumerate() {
let params = s.get("params").cloned().unwrap_or(Value::Null);
let r = if let Some(c) = str_of(s, "command") {
// Like `engine.execute`: `wait: false` starts a long command as a background job.
let wait = s.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(c, params, wait)
} else if let Some(m) = str_of(s, "method") {
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
} else {
Err(bad(format!("step {i} needs `command` or `method`")))
};
let was_error = r.is_err();
let result = match r {
Ok(v) => json!({"ok": true, "result": v}),
Err(e) => json!({"ok": false, "error": e.to_string()}),
};
if let Err(error) = reply_budget.charge(&result) {
failed += 1;
results.push(json!({"ok": false, "error": format!("batch response budget exceeded; current step may have completed: {error}")}));
break;
}
results.push(result);
if was_error {
failed += 1;
if stop {
break;
}
}
}
Ok(json!({"completed": results.len() - failed, "failed": failed, "results": results}))
}
}
/// Answer one request line. Malformed JSON gets an error reply with `id: null`.
pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => return json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
};
let id = req.get("id").cloned().unwrap_or(Value::Null);
let Some(method) = req.get("method").and_then(Value::as_str) else {
return json!({"id": id, "ok": false, "error": "missing `method`"});
};
let params = req.get("params").cloned().unwrap_or(Value::Null);
// Last-resort guard (AGENTS.md, Never crash): a command that panics answers this request
// with an error instead of taking down the connection (or the whole stdio server). Edits
// run on a copy of the document (`Session::edit`), so the session stays consistent and a
// poisoned lock is safe to keep using, the same as the MCP server does.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.lock().unwrap_or_else(PoisonError::into_inner).handle(method, params)))
.unwrap_or_else(|_| Err(AutomationError::Other(format!("internal error: `{method}` panicked"))));
match r {
Ok(v) => json!({"id": id, "ok": true, "result": v}),
Err(e) => json!({"id": id, "ok": false, "error": e.to_string()}),
}
}
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored. An over-long or non-UTF-8
/// line gets an error reply and is skipped; the session (and its open documents) keeps serving.
pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
let mut line = String::new();
loop {
let (reply, unread_rest) = match read_bounded_line(&mut r, &mut line) {
Ok(LineRead::Eof) => return Ok(()),
Ok(LineRead::TooLong) => (json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}), !line.ends_with('\n')),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => (json!({"id": null, "ok": false, "error": "request is not valid UTF-8"}), false),
Err(e) => return Err(e),
Ok(LineRead::Line) if line.trim().is_empty() => continue,
Ok(LineRead::Line) => (respond(h, &line), false),
};
write_reply(&mut w, &reply)?;
w.flush()?;
if unread_rest {
discard_rest_of_line(&mut r)?;
}
}
}
/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however
/// much it sends meanwhile (#4965); after that only the idle timeout applies.
fn serve_tcp_connection(h: &Mutex<Headless>, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> {
configure_stream(&stream)?;
let read = stream.try_clone()?;
let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline));
let mut out = stream;
let mut line = String::new();
let mut authenticated = false;
loop {
match read_bounded_line(&mut reader, &mut line)? {
LineRead::Eof => return Ok(()),
LineRead::TooLong => {
let reply = json!({
"id": null,
"ok": false,
"error": format!("request exceeds {MAX_REQUEST_BYTES} bytes"),
});
write_reply(&mut out, &reply)?;
out.flush()?;
return Ok(());
}
LineRead::Line if line.trim().is_empty() => continue,
LineRead::Line => {}
}
let reply = if authenticated {
repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line))
} else {
let (reply, ok) = authentication_reply(&line, token);
authenticated = ok;
if ok {
reader.get_mut().authenticated()?;
}
reply
};
write_reply(&mut out, &reply)?;
out.flush()?;
if !authenticated {
return Ok(());
}
}
}
/// Headless TCP daemons with no clients should eventually release their listening port (#3682).
const TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// Serve authenticated JSON lines on a loopback TCP address with one shared session.
/// Refuses non-loopback addresses, caps connections and request bytes, and closes the listener
/// after an idle period with no connections. Active clients are never evicted by this timer.
pub fn serve_tcp(addr: &str, h: Arc<Mutex<Headless>>, token: String, ready: impl FnOnce(std::net::SocketAddr)) -> Result<(), AutomationError> {
serve_tcp_until_idle(addr, h, token, ready, TCP_IDLE_TIMEOUT)
}
fn serve_tcp_until_idle(
addr: &str,
h: Arc<Mutex<Headless>>,
token: String,
ready: impl FnOnce(std::net::SocketAddr),
idle: Duration,
) -> Result<(), AutomationError> {
let listener = TcpListener::bind(addr).map_err(|e| AutomationError::Io(format!("bind {addr}: {e}")))?;
let local = listener.local_addr().map_err(|e| AutomationError::Io(e.to_string()))?;
if !local.ip().is_loopback() {
return Err(bad(format!("{addr} is not a loopback address")));
}
listener.set_nonblocking(true).map_err(|e| AutomationError::Io(e.to_string()))?;
ready(local);
let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
let token = Arc::new(token);
let active = Arc::new(AtomicUsize::new(0));
let mut last_active = Instant::now();
loop {
match listener.accept() {
Ok((mut stream, _)) => {
last_active = Instant::now();
let Some(permit) = limiter.try_acquire() else {
let _ = configure_stream(&stream);
let _ = writeln!(stream, "{}", json!({"id": null, "ok": false, "error": "connection limit reached"}));
continue;
};
// The listener is nonblocking; individual requests still use blocking socket I/O.
let _ = stream.set_nonblocking(false);
let h = h.clone();
let token = Arc::clone(&token);
let active = Arc::clone(&active);
active.fetch_add(1, Ordering::AcqRel);
std::thread::spawn(move || {
let _permit = permit;
let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE);
active.fetch_sub(1, Ordering::AcqRel);
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if active.load(Ordering::Acquire) != 0 {
last_active = Instant::now();
} else if last_active.elapsed() >= idle {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted) => {}
Err(e) => return Err(AutomationError::Io(e.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session() -> Mutex<Headless> {
Mutex::new(Headless::trusted_local())
}
#[test]
fn document_activation_reply_survives_engine_rpc() {
let h = session();
let mut g = h.lock().unwrap();
for _ in 0..2 {
g.handle("engine.execute", json!({"command": "file.new", "params": {"width": 4, "height": 4}})).unwrap();
}
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"offset": -1}})).unwrap();
assert_eq!(reply, json!({"document": 0}));
let reply = g.handle("engine.execute", json!({"command": "document.activate", "params": {"document": 1}})).unwrap();
assert_eq!(reply, json!({"document": 1}));
}
#[test]
fn execute_reports_invalid_filter_parameters_without_changing_history() {
let h = session();
assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true);
let before = h.lock().unwrap().inspect(None).unwrap();
for (params, key) in
[(json!({"radius":"big"}), "radius"), (json!({"radus":3}), "radus"), (json!({"radius":5000}), "radius"), (json!({"radius":-4}), "radius")]
{
for wait in [true, false] {
let request = json!({"method":"engine.execute","params":{"command":"filter.blur.gaussianBlur","params":params,"wait":wait}});
let reply = respond(&h, &request.to_string());
assert_eq!(reply["ok"], false);
let error = reply["error"].as_str().unwrap();
assert!(error.contains("filter.blur.gaussianBlur") && error.contains(key), "{error}");
assert_eq!(h.lock().unwrap().inspect(None).unwrap(), before);
}
}
}
/// Pins the reply shape documented in docs/control-protocol.md › Command list replies
/// (#3684): a bare JSON array (not `{"commands": [...]}`), one object per command with
/// exactly these keys (plus an optional `keywords` string), from `engine.commands` (filtered or not) and `command.list`.
#[test]
fn command_list_replies_are_documented_array_of_objects() {
let h = session();
let mut g = h.lock().unwrap();
let all = g.handle("engine.commands", json!({})).unwrap();
let filtered = g.handle("engine.commands", json!({"filter": "BLUR"})).unwrap();
let engine = g.handle("engine.execute", json!({"command": "command.list"})).unwrap();
for (name, reply) in [("engine.commands", &all), ("engine.commands {filter}", &filtered), ("command.list", &engine)] {
let items = reply.as_array().unwrap_or_else(|| panic!("{name} must reply with a bare array, got {reply}"));
assert!(!items.is_empty(), "{name}");
for c in items {
// `keywords` is optional: only commands with other search names carry it (#5060).
let mut keys: Vec<&str> = c.as_object().unwrap().keys().map(String::as_str).filter(|k| *k != "keywords").collect();
keys.sort_unstable();
assert!(c.get("keywords").is_none_or(Value::is_string), "{name}: {c}");
assert_eq!(keys, ["disabledReason", "enabled", "id", "label", "menu", "needsDocument", "params", "shortcut"], "{name}: {c}");
assert!(c["id"].is_string() && c["label"].is_string() && c["params"].is_string() && c["enabled"].is_boolean(), "{c}");
assert!(c["needsDocument"].is_boolean(), "{c}");
assert_eq!(c["disabledReason"].is_null(), c["enabled"] == true, "disabledReason is null exactly when enabled: {c}");
assert!(c["menu"].as_array().unwrap().iter().all(Value::is_string), "{c}");
assert!(c["shortcut"].is_string() || c["shortcut"].is_null(), "{c}");
}
}
let n_all = all.as_array().unwrap().len();
assert_eq!(engine.as_array().unwrap().len(), n_all);
let filtered = filtered.as_array().unwrap();
assert!(filtered.len() < n_all);
assert!(filtered.iter().all(|c| format!("{} {}", c["id"], c["label"]).to_lowercase().contains("blur")));
let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap();
assert_eq!((&new["menu"], &new["shortcut"], &new["enabled"]), (&json!(["File"]), &json!("Cmd+N"), &json!(true)), "{new}");
assert_eq!((&new["disabledReason"], &new["needsDocument"]), (&Value::Null, &json!(false)), "{new}");
}
#[test]
fn lines_round_trip_and_errors() {
let h = session();
let input = concat!(
r#"{"id":1,"method":"doc.new","params":{"width":64,"height":32,"background":"white"}}"#,
"\n",
"\n",
r#"{"id":2,"method":"engine.execute","params":{"command":"layer.new.layer","params":{"name":"Ink"}}}"#,
"\n",
r#"{"id":3,"method":"doc.inspect"}"#,
"\n",
r#"{"id":4,"method":"nope"}"#,
"\n",
"not json\n",
);
let mut out = Vec::new();
serve_lines(&h, input.as_bytes(), &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(), 5);
assert!(replies[0]["ok"].as_bool().unwrap());
assert_eq!(replies[1]["id"], 2);
assert!(replies[2]["result"].to_string().contains("Ink"));
assert!(!replies[3]["ok"].as_bool().unwrap());
assert!(replies[3]["error"].as_str().unwrap().contains("unknown method"));
assert_eq!(replies[4]["id"], Value::Null);
}
/// Regression: after a panic while the session lock was held, every later request
/// failed with "session lock poisoned"; the server now keeps serving.
#[test]
fn poisoned_session_keeps_serving() {
let h = session();
let _ = std::thread::scope(|sc| {
sc.spawn(|| {
let _g = h.lock().unwrap();
panic!("poison the session lock");
})
.join()
});
assert!(h.is_poisoned());
let r = respond(&h, r#"{"id":1,"method":"doc.new","params":{"width":8,"height":8}}"#);
assert_eq!(r["ok"], true, "{r}");
}
#[test]
fn batch_stops_on_error_unless_told_not_to() {
let h = session();
let mut g = h.lock().unwrap();
g.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
let steps = json!([
{"command": "layer.new.layer", "params": {"name": "A"}},
{"command": "no.such.command"},
{"command": "layer.new.layer", "params": {"name": "B"}},
{"method": "doc.inspect"}
]);
let r = g.handle("batch", json!({"steps": steps})).unwrap();
assert_eq!(r["completed"], 1);
assert_eq!(r["failed"], 1);
assert_eq!(r["results"].as_array().unwrap().len(), 2);
let r = g.handle("batch", json!({"steps": steps, "stopOnError": false})).unwrap();
assert_eq!(r["completed"], 3);
let insp = &r["results"][3]["result"];
assert!(insp.to_string().contains("\"B\""));
}
#[test]
fn batch_rejects_too_many_steps() {
let h = session();
let mut g = h.lock().unwrap();
let steps = vec![json!({"command": "command.list"}); MAX_BATCH_STEPS + 1];
let error = g.handle("batch", json!({"steps": steps})).unwrap_err();
assert!(error.to_string().contains("maximum is 256"));
}
#[test]
fn render_to_file_and_base64() {
let h = session();
let mut g = h.lock().unwrap();
g.handle("doc.new", json!({"width": 40, "height": 20, "background": "black"})).unwrap();
let b = g.handle("doc.render", json!({"maxSide": 10})).unwrap();
let png = base64::engine::general_purpose::STANDARD.decode(b["base64"].as_str().unwrap()).unwrap();
let img = photocraft_codecs::decode(&png).unwrap();
assert_eq!(img.dimensions(), (10, 5));
let path = std::env::temp_dir().join(format!("pc-rpc-{}.png", std::process::id()));
let r = g.handle("doc.render", json!({"path": path.to_string_lossy(), "maxSide": 0})).unwrap();
assert!(r["bytes"].as_u64().unwrap() > 0);
assert_eq!(photocraft_codecs::decode(&std::fs::read(&path).unwrap()).unwrap().dimensions(), (40, 20));
let _ = std::fs::remove_file(path);
}
#[test]
fn background_jobs_list_wait_and_cancel() {
let mut h = Headless::new();
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap();
h.handle("engine.execute", json!({"command": "edit.fill", "params": {"color": "#808080"}})).unwrap();
// Default: waits, the result is the command's.
let r = h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 20}})).unwrap();
assert!(r["filter"].is_object(), "{r}");
// wait: false returns a job id at once.
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
let job = r["job"].as_u64().unwrap();
let listed = h.handle("jobs.list", json!({})).unwrap();
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
// Wait for it via jobs.list (which applies finished jobs).
let t = std::time::Instant::now();
loop {
let l = h.handle("jobs.list", json!({})).unwrap();
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
if state == Some(json!("done")) {
break;
}
assert!(t.elapsed().as_secs() < 60, "{l}");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// Cancel: a second job, cancelled at once, leaves the document unchanged.
let before = h.session.active().unwrap().doc.clone();
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
let job = r["job"].as_u64().unwrap();
h.handle("jobs.cancel", json!({"job": job})).unwrap();
assert!(h.handle("jobs.cancel", json!({"job": "nope"})).is_err());
let l = h.handle("jobs.list", json!({})).unwrap();
assert_eq!(l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).unwrap()["state"], "cancelled");
assert!(std::sync::Arc::ptr_eq(&h.session.active().unwrap().doc, &before));
}
#[test]
fn batch_steps_can_start_background_jobs() {
let mut h = Headless::new();
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
let steps = json!([
{"command": "layer.new.layer"},
{"command": "edit.fill", "params": {"color": "#808080"}},
{"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false}
]);
let r = h.handle("batch", json!({"steps": steps})).unwrap();
assert_eq!(r["completed"], 3, "{r}");
let job = r["results"][2]["result"]["job"].as_u64().unwrap_or_else(|| panic!("no job id: {r}"));
assert_eq!(r["results"][2]["result"]["pending"], true);
let listed = h.handle("jobs.list", json!({})).unwrap();
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
let t = std::time::Instant::now();
while h.session.jobs().iter().any(|j| j.id.0 == job) {
assert!(t.elapsed().as_secs() < 60);
std::thread::sleep(std::time::Duration::from_millis(5));
h.handle("jobs.list", json!({})).unwrap();
}
}
/// Regression (#503): a finished `wait: false` job was only applied by a later command or
/// `jobs.list`, so save, inspect, render and `session.list` still saw the document without it.
#[test]
fn every_request_sees_a_finished_background_job() {
let dir = std::env::temp_dir().join(format!("pc-rpc-job-sync-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (before, after) = (dir.join("before.png"), dir.join("after.png"));
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 64, "height": 48})).unwrap();
h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 50}})).unwrap();
h.handle("doc.save", json!({"path": before.to_string_lossy()})).unwrap();
let render = |h: &mut Headless| h.handle("doc.render", json!({"maxSide": 0})).unwrap()["base64"].clone();
let unblurred = render(&mut h);
let revision = |h: &mut Headless| h.handle("session.list", json!({})).unwrap()["documents"][0]["revision"].clone();
let start = revision(&mut h);
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 4}, "wait": false})).unwrap();
// Only reading requests from here on: no command and no `jobs.list`.
let t = std::time::Instant::now();
while revision(&mut h) == start {
assert!(t.elapsed().as_secs() < 60, "session.list never saw the finished job");
std::thread::sleep(std::time::Duration::from_millis(5));
}
let history = h.handle("doc.inspect", json!({})).unwrap()["history"].clone();
assert_eq!(history.as_array().unwrap().last().unwrap(), "Gaussian Blur", "{history}");
h.handle("doc.save", json!({"path": after.to_string_lossy()})).unwrap();
let decode = |p: &std::path::Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap().to_rgba8();
assert_ne!(decode(&before), decode(&after), "the export holds the blur");
assert_ne!(render(&mut h), unblurred, "the preview holds the blur");
std::fs::remove_dir_all(dir).unwrap();
}
/// Regression: `quality` 85.0 or "85" was ignored (a lossless WebP, reply ok) and 0 or 500 was
/// clamped, where the CLI refuses anything but a whole number from 1 to 100.
#[test]
fn doc_save_rejects_a_bad_quality_instead_of_ignoring_or_clamping_it() {
let dir = std::env::temp_dir().join(format!("pc-rpc-save-quality-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("x.webp");
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 16, "height": 16})).unwrap();
for q in [json!(85.0), json!("85"), json!(0), json!(500), json!(-5), json!(true)] {
let err = h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": q})).unwrap_err().to_string();
assert!(err.contains("quality"), "{q}: {err}");
assert!(!path.exists(), "{q} wrote a file");
}
h.handle("doc.save", json!({"path": path.to_string_lossy(), "quality": 85})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(bytes.get(12..16), Some(&b"VP8 "[..]), "a WebP saved with a quality is lossy");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
let mut h = Headless::new();
h.handle("doc.new", json!({"width": 2049, "height": 1})).unwrap();
for max in [json!(0), json!(2049), json!(u64::from(u32::MAX) + 1), json!(-1), json!(1.5)] {
assert!(h.handle("doc.render", json!({"maxSide": max})).is_err());
}
// A smaller preview still works, and rejection leaves the source document intact.
let rendered = h.handle("doc.render", json!({"maxSide": 32})).unwrap();
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (32, 1));
assert_eq!(h.session.active().unwrap().doc.size.width, 2049);
}
#[test]
fn stdio_rejects_oversized_frames_before_dispatch() {
let h = Mutex::new(Headless::new());
let input = " ".repeat(MAX_REQUEST_BYTES + 1);
let mut out = Vec::new();
serve_lines(&h, input.as_bytes(), &mut out).unwrap();
let reply: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(reply["ok"], false);
assert!(reply["error"].as_str().unwrap().contains("request exceeds"));
assert!(h.lock().unwrap().session.documents().is_empty());
}
/// Regression (#505): an over-long or non-UTF-8 line ended the stdio session, losing every
/// open document. Now it gets one error reply, the rest of the line is skipped (never
/// dispatched), and the next request is served.
#[test]
fn stdio_skips_a_rejected_line_and_keeps_the_session() {
let h = Mutex::new(Headless::new());
let mut input = b"{\"id\":1,\"method\":\"doc.new\",\"params\":{\"width\":8,\"height\":8}}\n".to_vec();
input.extend(" ".repeat(MAX_REQUEST_BYTES + 1).into_bytes());
input.extend(b"{\"id\":9,\"method\":\"doc.close\"}\n");
input.extend(b"\xff\xfe{\"id\":8,\"method\":\"doc.close\"}\n");
input.extend(b"{\"id\":2,\"method\":\"session.list\"}\n");
let mut out = Vec::new();
serve_lines(&h, input.as_slice(), &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(), 4, "{replies:?}");
assert_eq!(replies[0]["ok"], true);
assert!(replies[1]["error"].as_str().unwrap().contains("request exceeds"), "{}", replies[1]);
assert!(replies[2]["error"].as_str().unwrap().contains("not valid UTF-8"), "{}", replies[2]);
assert_eq!(replies[3]["id"], 2);
assert_eq!(replies[3]["result"]["documents"].as_array().unwrap().len(), 1, "the document stays open");
}
#[test]
fn preview_rejection_precedes_output_file_creation() {
let root = std::env::temp_dir().join(format!("pc-preview-budget-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let workspace = crate::AuthorizedWorkspace::new(None, Some(&root)).unwrap();
let mut h = Headless::with_workspace(workspace);
h.handle("doc.new", json!({"width": 16, "height": 8})).unwrap();
let result = h.handle("doc.render", json!({"maxSide": 2049, "path": "rejected.png"}));
assert!(result.unwrap_err().to_string().contains("preview side exceeds"));
assert!(!root.join("rejected.png").exists());
assert!(h.handle("doc.render", json!({"maxSide": 16, "path": "accepted.png"})).is_ok());
assert!(root.join("accepted.png").exists());
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_file(root.join("accepted.png")).unwrap();
std::fs::remove_dir(root).unwrap();
}
#[test]
fn doc_place_reads_through_the_read_root_and_never_outside_it() {
let base = std::env::temp_dir().join(format!("pc-doc-place-{}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
for dir in [&root, &outside] {
std::fs::create_dir_all(dir).unwrap();
}
let img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgb, vec![42; 8 * 6 * 3]).unwrap();
let png = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
std::fs::write(root.join("logo.png"), &png).unwrap();
std::fs::write(outside.join("logo.png"), &png).unwrap();
let absolute = outside.join("logo.png").to_string_lossy().into_owned();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let placed = h.handle("doc.place", json!({"path": "logo.png", "scale": 50})).unwrap();
assert!(placed["layer"].as_u64().is_some(), "{placed}");
assert_eq!(placed["scale"].as_f64(), Some(50.0), "the options reach the command: {placed}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
// A path outside the read root is refused the way the ambient command is (#2810).
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
assert!(refused.contains(reason), "{refused}");
let inspected = h.handle("doc.inspect", json!({})).unwrap();
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "nothing was placed: {refused}");
}
// Trusted local keeps the ambient command: that caller may read the path itself.
let mut t = Headless::trusted_local();
t.handle("doc.new", json!({"width": 40, "height": 30})).unwrap();
let trusted = t.handle("doc.place", json!({"path": root.join("logo.png").to_string_lossy()})).unwrap();
assert!(trusted["layer"].as_u64().is_some(), "{trusted}");
// Release directory capabilities before cleanup on Windows.
drop(t);
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_layers_reports_skips_in_workspace_and_trusted_sessions() {
for trusted in [false, true] {
let base = std::env::temp_dir().join(format!("pc-export-skips-{}-{trusted}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let mut h =
if trusted { Headless::trusted_local() } else { Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&base), Some(&base)).unwrap()) };
h.handle("doc.new", json!({"width": 4, "height": 4, "background": "white", "name": "card"})).unwrap();
let adjustment = h.handle("engine.execute", json!({"command": "layer.newAdjustmentLayer.levels"})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"name": "Levels"}})).unwrap();
let hidden = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Hidden"}})).unwrap()["layer"].clone();
h.handle("engine.execute", json!({"command": "layer.setProps", "params": {"visible": false}})).unwrap();
// Exercise the actual JSON-lines serve envelope, not only direct dispatch.
let h = Mutex::new(h);
let out_dir = base.join("out").to_string_lossy().into_owned();
let request = json!({"id": 8491, "method": "doc.exportLayers", "params": {"dir": out_dir}}).to_string() + "\n";
let mut output = Vec::new();
serve_lines(&h, request.as_bytes(), &mut output).unwrap();
let reply: Value = serde_json::from_slice(&output).unwrap();
assert_eq!(reply["id"], 8491);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"]["files"], json!([format!("{out_dir}/card_0001_Background.png")]));
if !trusted {
assert_eq!(reply["result"]["count"], 1);
}
assert_eq!(
reply["result"]["skipped"],
json!([
{"layer": hidden, "name": "Hidden", "reason": "hidden by visibleOnly"},
{"layer": adjustment, "name": "Levels", "reason": "adjustment layer has no pixels of its own"}
])
);
assert_eq!(std::fs::read_dir(base.join("out")).unwrap().count(), 1);
let mut h = h.into_inner().unwrap();
let all_dir = base.join("all").to_string_lossy().into_owned();
let all = h.export_layers(None, &all_dir, &json!({"visibleOnly": false})).unwrap();
assert_eq!(all["files"].as_array().unwrap().len(), 2);
assert_eq!(all["skipped"].as_array().unwrap().len(), 1);
// Workspace no-overwrite errors still fail and remove only newly created files.
if !trusted {
let collision = base.join("collision");
std::fs::create_dir_all(&collision).unwrap();
std::fs::write(collision.join("card_0002_Background.png"), b"keep").unwrap();
assert!(h.export_layers(None, &collision.to_string_lossy(), &json!({"visibleOnly": false})).is_err());
assert_eq!(std::fs::read_dir(&collision).unwrap().count(), 1);
assert_eq!(std::fs::read(collision.join("card_0002_Background.png")).unwrap(), b"keep");
}
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
}
#[test]
fn doc_export_layers_writes_only_beneath_the_write_root() {
let base = std::env::temp_dir().join(format!("pc-doc-export-layers-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 12, "height": 8, "background": "white", "name": "card"})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {"name": "Badge"}})).unwrap();
let out = root.join("out");
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": "png", "prefix": "card"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
let mut names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names.len(), 2, "{exported}");
for name in names {
assert!(name.starts_with("card_") && name.ends_with(".png"), "{name}");
let bytes = std::fs::read(out.join(&name)).unwrap();
assert_eq!(photocraft_codecs::decode(&bytes).unwrap().dimensions(), (12, 8), "{name}");
}
// Outside the write root nothing is created, not even the directory.
let escaped = base.join("escaped");
let refused = h.handle("doc.exportLayers", json!({"dir": escaped.to_string_lossy()})).unwrap_err().to_string();
assert!(refused.contains("must be inside the automation root"), "{refused}");
assert!(!escaped.exists());
// The ambient command keeps its refusal for the very same directory.
let ambient =
h.handle("engine.execute", json!({"command": "file.export.layersToFiles", "params": {"dir": out.to_string_lossy()}})).unwrap_err().to_string();
assert!(ambient.contains("ambient filesystem paths"), "{ambient}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
let base = std::env::temp_dir().join(format!("pc-doc-data-sets-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let inspected = h.handle("doc.inspect", json!({})).unwrap();
let layer = inspected["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "one", "values": [{"variable": "show", "kind": "visibility", "value": true}]},
{"name": "two", "values": [{"variable": "show", "kind": "visibility", "value": false}]},
]}}),
)
.unwrap();
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap();
assert_eq!(exported["count"].as_u64(), Some(2), "{exported}");
assert_eq!(std::fs::read_dir(&sets).unwrap().count(), 2, "{exported}");
// Colliding names are refused as in the command, before any file is written.
let clash = root.join("clash");
let refused = h.handle("doc.exportDataSets", json!({"dir": clash.to_string_lossy(), "naming": "{document}"})).unwrap_err().to_string();
assert!(refused.contains("{index}"), "{refused}");
assert_eq!(std::fs::read_dir(&clash).unwrap().count(), 0, "no file of the refused batch");
// A CSV from the read root, and one outside it.
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let imported = h.handle("doc.importDataSets", json!({"path": "sets.csv"})).unwrap();
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
assert!(outside.contains("must be inside the automation root"), "{outside}");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn indexed_place_and_data_set_methods_keep_the_active_document() {
let base = std::env::temp_dir().join(format!("pc-doc-index-active-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let img = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, vec![42; 4 * 4 * 3]).unwrap();
std::fs::write(root.join("logo.png"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
std::fs::write(root.join("sets.csv"), "Data Set,show\none,true\ntwo,false\n").unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "first"})).unwrap();
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "second"})).unwrap();
let layer = h.handle("doc.inspect", json!({})).unwrap()["layers"][0]["id"].as_u64().expect("the layer id");
h.handle("engine.execute", json!({"command": "image.variables.define", "params": {"defs": [{"name": "show", "layer": layer, "type": "visibility"}]}}))
.unwrap();
h.handle("doc.select", json!({"index": 0})).unwrap();
let steps = [
("doc.place", json!({"path": "logo.png", "index": 1}), true),
("doc.importDataSets", json!({"path": "sets.csv", "index": 1}), true),
("doc.exportLayers", json!({"dir": "layers", "index": 1}), true),
("doc.exportDataSets", json!({"dir": "sets", "index": 1}), true),
// A failing call restores the active document too.
("doc.importDataSets", json!({"path": "missing.csv", "index": 1}), false),
];
for (method, params, succeeds) in steps {
let result = h.handle(method, params.clone());
assert_eq!(result.is_ok(), succeeds, "{method} {params}: {result:?}");
assert_eq!(h.session.active_index(), Some(0), "{method} {params} must not select document 1");
}
assert_eq!(h.session.documents()[0].doc.layer_count(), 1, "nothing was placed into the active document");
assert_eq!(h.session.documents()[1].doc.layer_count(), 2, "the placed layer is in document 1");
assert_eq!(std::fs::read_dir(root.join("sets")).unwrap().count(), 2, "document 1's data sets were exported");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn doc_export_data_sets_judges_pixel_replacement_paths_like_apply() {
let base = std::env::temp_dir().join(format!("pc-doc-data-set-pixels-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(4, 4, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(16)).unwrap();
let photo = base.join("photo.png");
std::fs::write(&photo, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.new", json!({"width": 8, "height": 8, "background": "white", "name": "card"})).unwrap();
let layer = h.handle("engine.execute", json!({"command": "layer.new.layer", "params": {}})).unwrap()["layer"].clone();
h.handle(
"engine.execute",
json!({"command": "image.variables.define", "params": {"defs": [{"name": "photo", "layer": layer, "type": "pixelReplacement"}]}}),
)
.unwrap();
// Storing the path is allowed; reading it is what the gate judges.
h.handle(
"engine.execute",
json!({"command": "image.variables.dataSets", "params": {"dataSets": [
{"name": "swap", "values": [{"variable": "photo", "kind": "pixels", "value": photo.to_string_lossy()}]},
]}}),
)
.unwrap();
let applied = h.handle("engine.execute", json!({"command": "image.applyDataSet", "params": {"name": "swap"}})).unwrap_err().to_string();
assert!(applied.contains("ambient filesystem paths"), "{applied}");
let sets = root.join("sets");
let exported = h.handle("doc.exportDataSets", json!({"dir": sets.to_string_lossy(), "format": "png"})).unwrap_err().to_string();
assert!(exported.contains("ambient filesystem paths"), "{exported}");
assert_eq!(std::fs::read_dir(&sets).map_or(0, Iterator::count), 0, "nothing of the refused batch is written");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_psd_saves_embed_no_file_a_linked_smart_object_names() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0001";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-psd-linked-embed-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
// A trusted export embeds the linked file, as before.
assert!(has_marker(&photocraft_io::export(&state.doc, "psd", &Default::default()).unwrap().bytes));
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let saved = h.handle("doc.save", json!({"path": "out.psd"})).unwrap();
let warnings = saved["warnings"].to_string();
assert!(warnings.contains("written as pixels"), "{warnings}");
assert!(!has_marker(&std::fs::read(root.join("out.psd")).unwrap()), "the linked file outside the roots is not embedded");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn allowed_filters_do_not_rerender_a_smart_object_from_its_linked_file() {
use photocraft_engine::doc::{LayerContent, SmartSource};
let base = std::env::temp_dir().join(format!("pc-linked-rerender-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let red = photocraft_codecs::Image::from_u8(8, 8, photocraft_codecs::ChannelLayout::Rgb, [255, 0, 0].repeat(64)).unwrap();
let outside = base.join("red.png");
std::fs::write(&outside, photocraft_codecs::encode(&red, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
// A document whose white smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("p.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "p.pcraft"})).unwrap();
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 0.5}})).unwrap();
let doc = &h.session.active().unwrap().doc;
let LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("smart object") };
assert_eq!(smart.smart_filters.len(), 1, "the filter is still recorded");
assert_eq!(smart.cache.as_ref().unwrap().rgba(4, 4), [1.0, 1.0, 1.0, 1.0], "the linked file outside the roots is not read");
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() {
use photocraft_engine::doc::{LayerContent, SmartSource};
const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002";
let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER);
let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id()));
let root = base.join("workspace");
std::fs::create_dir_all(&root).unwrap();
let outside = base.join("outside.txt");
std::fs::write(&outside, MARKER).unwrap();
// A document whose smart object names a file outside the roots as its source.
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap();
session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let state = session.active_mut().unwrap();
let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") };
smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() };
std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap();
let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap());
h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap();
let out = root.join("layers");
for format in ["psd", "psb"] {
let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap();
assert_eq!(exported["count"], 1, "{exported}");
}
let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect();
assert_eq!(files.len(), 2, "{files:?}");
for file in files {
assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display());
}
// Release directory capabilities before cleanup on Windows.
drop(h);
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn trusted_local_render_keeps_full_size_behavior() {
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 2049, "height": 1})).unwrap();
let rendered = h.handle("doc.render", json!({"maxSide": 0})).unwrap();
let png = base64::engine::general_purpose::STANDARD.decode(rendered["base64"].as_str().unwrap()).unwrap();
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (2049, 1));
}
#[test]
fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
let mut h = Headless::new();
let listed = h.handle("methods", Value::Null).unwrap();
let listed: Vec<&str> = listed.as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
assert_eq!(listed, METHODS);
// The job methods `engine.execute {"wait": false}` depends on are discoverable (#414).
assert!(listed.contains(&"jobs.list") && listed.contains(&"jobs.cancel"));
for m in METHODS {
if let Err(e) = h.handle(m, Value::Null) {
assert!(!e.to_string().contains("unknown method"), "{m} is listed but not served: {e}");
}
}
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
}
#[test]
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
let mut h = Headless::new();
let mut steps = vec![json!({"command": "command.list"}); MAX_BATCH_STEPS - 1];
steps.push(json!({"command": "file.new", "params": {"width": 8, "height": 8}}));
let result = h.batch(&json!({"steps": steps, "stopOnError": false})).unwrap();
assert_eq!(result["failed"], 1);
assert!(result["results"].as_array().unwrap().last().unwrap()["error"].as_str().unwrap().contains("batch response budget exceeded"));
assert!(h.session.documents().is_empty(), "later edit must not execute after the quota is exhausted");
assert!(crate::budgets::json_bytes(&result).is_ok());
assert!(h.handle("methods", Value::Null).is_ok());
}
#[test]
fn idle_tcp_daemon_releases_its_port_but_keeps_active_clients() {
use std::io::BufReader;
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let result = serve_tcp_until_idle("127.0.0.1:0", h, TOKEN.to_owned(), move |addr| ready_tx.send(addr).unwrap(), Duration::from_millis(200));
done_tx.send(result).unwrap();
});
let addr = ready_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let mut client = std::net::TcpStream::connect(addr).unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
let mut reader = BufReader::new(client.try_clone().unwrap());
writeln!(client, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let auth: Value = serde_json::from_str(&line).unwrap();
assert_eq!(auth["result"]["authenticated"], true);
// The port stays owned well past the idle limit while a client is connected.
assert!(
matches!(done_rx.recv_timeout(Duration::from_millis(450)), Err(std::sync::mpsc::RecvTimeoutError::Timeout)),
"daemon exited with a live client"
);
drop(reader);
drop(client);
assert!(done_rx.recv_timeout(Duration::from_secs(3)).unwrap().is_ok(), "idle daemon did not exit cleanly");
server.join().unwrap();
assert!(TcpListener::bind(addr).is_ok(), "idle listener still owns its port");
}
#[test]
fn tcp_serves_loopback() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let auth: Value = serde_json::from_str(&line).unwrap();
assert_eq!(auth["result"]["authenticated"], true);
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["id"], "a");
assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch"));
}
/// #4965: a connection that keeps sending without authenticating is closed at the deadline,
/// and one that authenticated in time keeps being served after it.
#[test]
fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() {
use std::io::{BufReader, Write as _};
use std::time::{Duration, Instant};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let h = Mutex::new(Headless::new());
let mut results = Vec::new();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
let started = Instant::now();
let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300));
results.push((r.map_err(|e| e.kind()), started.elapsed()));
}
results
});
// Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout.
let mut slow = std::net::TcpStream::connect(addr).unwrap();
for _ in 0..150 {
if slow.write_all(b" ").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
drop(slow);
// Authenticates at once, then asks again 300 ms after the deadline.
let mut s = std::net::TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["result"]["authenticated"], true);
std::thread::sleep(Duration::from_millis(600));
writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap()["id"], "a");
drop(reader);
drop(s);
let results = server.join().unwrap();
let (slow_result, slow_time) = &results[0];
assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}");
assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}");
assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client");
}
#[test]
fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
let mut reader = BufReader::new(s.try_clone().unwrap());
let mut line = String::new();
for id in ["auth", "again"] {
writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
}
let again: Value = serde_json::from_str(&line).unwrap();
assert_eq!(again["id"], "again");
assert_eq!(again["ok"], false);
assert_eq!(again["error"], "already authenticated");
writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap();
line.clear();
reader.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["id"], "m");
assert_eq!(v["ok"], true);
}
#[test]
fn tcp_rejects_requests_before_authentication() {
use std::io::{BufReader, Write as _};
const TOKEN: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let h = Arc::new(Mutex::new(Headless::new()));
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap());
});
let addr = rx.recv().unwrap();
let mut s = std::net::TcpStream::connect(addr).unwrap();
writeln!(s, r#"{{"id":1,"method":"methods"}}"#).unwrap();
let mut line = String::new();
BufReader::new(s).read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ok"], false);
assert_eq!(v["error"], "authentication required");
assert!(v.get("result").is_none());
}
#[test]
fn invalid_document_index_is_rejected_not_the_active_document() {
let h = session();
for _ in 0..2 {
assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true);
}
let count = |h: &Mutex<Headless>| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len();
for index in [json!("0"), json!(-1), json!(0.5), json!(true)] {
for method in ["doc.close", "doc.inspect", "doc.select"] {
let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string());
assert_eq!(reply["ok"], false, "{method} {index}: {reply}");
assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}");
}
assert_eq!(count(&h), 2, "index {index} closed a document");
}
// Null means "the active document", like a missing `index`.
assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true);
// A valid index still closes that document.
let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(count(&h), 1);
}
}