diff --git a/crates/automation/README.md b/crates/automation/README.md index f2e4ec92..d702b7ed 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -25,7 +25,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …). - `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document. - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. -- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. +- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. ## Text search and cache limits diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index accb76b9..6e631b70 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -858,6 +858,10 @@ impl Automation { } fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { let doc = self.doc(a)?; let id = doc.id; let target = match a.opt_str("path")? { @@ -875,7 +879,7 @@ impl Automation { // A save that adds or removes encryption, or must not keep the old revision (Sanitize, // redaction), is rewritten in full even when an incremental save was asked for. let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - write_atomic(&target, &bytes)?; + write(&target, &bytes)?; let path = target.to_string_lossy().into_owned(); self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) })) @@ -2306,13 +2310,24 @@ fn unused(dir: &Path, stem: &str) -> PathBuf { (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) } -/// Write via a temporary file in the same directory, then rename over the target. +/// Write and sync a temporary file in the same directory, then rename over the target. +/// This flushes staged contents; it does not promise parent-directory durability after a +/// power loss. Filesystems that refuse sync cause the save to fail before publication. fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { write_atomic_with(path, bytes, staging_suffixes()) } /// [`write_atomic`], trying the staging names that `suffixes` give. fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> Result<()> { use std::io::Write; // A folder can't be replaced, and staging beside it could land outside the root: "." names // the root itself, whose parent isn't ours to write in. @@ -2327,7 +2342,9 @@ fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator Vec { let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();