mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
CLI: pdfcraft-cli ui refuses a control file another user could plant (#597)
* Security: pdfcraft-cli ui refuses a control file another user could plant `pdfcraft-cli ui --control FILE` sent the token and every command, typed text included, to whatever port FILE named, without checking who owned it. The README suggested `/tmp/pc.json`; in a shared /tmp another local user can create that file first, so the CLI talked to their listener. The CLI now refuses a symbolic link and anything but a regular file, a file over 64 KiB, and on Unix a file owned by another user or with any group or other permission bits (the app writes 600). The owner and mode are read from the opened file, and its device and inode must match the path's, so a swap after the check is refused too. std has no geteuid and unsafe is forbidden, so the effective uid is the owner of a file the CLI creates in the temp folder and removes. The README now uses a per-user path. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Security: stop the app when --control can't be published A control file that couldn't be written was only logged, and the app ran on with a listener nobody could reach. The failure can mean another user already created a file at a shared path to receive the commands, and a script started with --control would otherwise drive nothing without being told. The app now exits with the error, before any document opens, so no work is lost. The README and the app's usage comment say so. Co-Authored-By: Claude Opus 5.5 <[email protected]> * CLI: pdfcraft-cli ui refuses a control-file port above 65535 `ui` truncated the control file's port with `as u16`, so 65536 + N reached port N, and the token and command went to whatever listened there. A port that doesn't fit is now refused, before connecting. Co-Authored-By: Claude Opus 5.5 <[email protected]> * CLI: learn the effective uid from rustix, not a temp-file probe Review on #597: the probe made `pdfcraft-cli ui` depend on a writable temp folder to judge a control file somewhere else, created and removed a file on every call, and could leave one behind. rustix's `geteuid` is safe and can't fail, and rustix is already in Cargo.lock; it is a Unix-only dependency of the CLI, so Windows builds don't compile it. A control file of your own now works with TMPDIR pointing at a folder that doesn't exist; the probe refused it. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
6 files changed
+370
-17
No files matched your search
Generated
+1
@@ -3604,6 +3604,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-render",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -303,15 +303,17 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
|
||||
|
||||
### Driving the app itself
|
||||
|
||||
Start the desktop app with `pdfcraft --control /tmp/pc.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
|
||||
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
|
||||
|
||||
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
|
||||
|
||||
```sh
|
||||
pdfcraft-cli ui --control /tmp/pc.json inspect query=rotate # find widgets
|
||||
pdfcraft-cli ui --control /tmp/pc.json click label="Organize pages"
|
||||
pdfcraft-cli ui --control /tmp/pc.json key key=K modifiers='["command"]'
|
||||
pdfcraft-cli ui --control /tmp/pc.json command id=comment.square # pick a tool, then draw:
|
||||
pdfcraft-cli ui --control /tmp/pc.json drag from='[400,300]' to='[600,420]'
|
||||
pdfcraft-cli ui --control /tmp/pc.json screenshot --out window.png
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json click label="Organize pages"
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json key key=K modifiers='["command"]'
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json command id=comment.square # pick a tool, then draw:
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json drag from='[400,300]' to='[600,420]'
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json screenshot --out window.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,11 @@ pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
|
||||
# Already in Cargo.lock through the GUI crates.
|
||||
rustix = { version = "1", features = ["process"] }
|
||||
|
||||
[features]
|
||||
default = ["mcp"]
|
||||
# The opt-in MCP server (`pdfcraft-cli mcp`). Build with --no-default-features to leave it out.
|
||||
|
||||
@@ -644,9 +644,10 @@ fn mcp(args: &[String]) -> Result<(), CliError> {
|
||||
fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?;
|
||||
let info: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?).map_err(|e| format!("{file}: {e}"))?;
|
||||
let info = read_control_file(file)?;
|
||||
let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?;
|
||||
// Refused rather than truncated, which would quietly reach some other port.
|
||||
let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?;
|
||||
let token = info["token"].as_str().ok_or(format!("{file}: no token"))?;
|
||||
let pos = positional(args);
|
||||
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
|
||||
@@ -656,8 +657,8 @@ fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?;
|
||||
params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())));
|
||||
}
|
||||
let stream = std::net::TcpStream::connect(("127.0.0.1", port as u16))
|
||||
.map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
|
||||
let stream =
|
||||
std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?;
|
||||
let mut write = stream.try_clone().map_err(|e| e.to_string())?;
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
@@ -681,3 +682,86 @@ fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Most a control file may hold. The app writes under 100 bytes.
|
||||
const CONTROL_FILE_MAX: u64 = 64 * 1024;
|
||||
|
||||
const CONTROL_FILE_ADVICE: &str =
|
||||
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
|
||||
|
||||
/// Read the file written by `pdfcraft --control FILE`.
|
||||
///
|
||||
/// It names the port that receives the token and every command, typed text included, so it must
|
||||
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
|
||||
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
|
||||
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
|
||||
/// else or open to other users (the app writes it with mode 600).
|
||||
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
|
||||
use std::io::Read as _;
|
||||
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
if at_path.file_type().is_symlink() {
|
||||
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
if !at_path.is_file() {
|
||||
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
|
||||
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
|
||||
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
|
||||
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
|
||||
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
|
||||
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
|
||||
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|e| format!("{file}: {e}"))
|
||||
}
|
||||
|
||||
/// Why user `me` can't trust a control file with this owner and mode, if they can't.
|
||||
#[cfg(any(test, unix))]
|
||||
fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option<String> {
|
||||
if owner != me {
|
||||
return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands"));
|
||||
}
|
||||
let permissions = mode & 0o7777;
|
||||
(permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)"))
|
||||
}
|
||||
|
||||
/// The effective user id: the owner of the files this process creates, and so of a control file
|
||||
/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is
|
||||
/// safe and can't fail.
|
||||
#[cfg(unix)]
|
||||
fn current_uid() -> u32 {
|
||||
rustix::process::geteuid().as_raw()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::control_file_problem;
|
||||
|
||||
#[test]
|
||||
fn a_control_file_must_be_yours_and_private() {
|
||||
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
|
||||
assert_eq!(control_file_problem(1000, 0o100600, 1000), None);
|
||||
assert_eq!(control_file_problem(1000, 0o600, 1000), None);
|
||||
assert_eq!(control_file_problem(1000, 0o400, 1000), None);
|
||||
let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default();
|
||||
assert!(other.contains("owned by another user") && other.contains("1001"), "{other}");
|
||||
// Root gets no exception: a file a user planted is still theirs.
|
||||
assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user")));
|
||||
for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] {
|
||||
let open = control_file_problem(1000, mode, 1000).unwrap_or_default();
|
||||
assert!(open.contains("other users"), "{mode:o}: {open}");
|
||||
}
|
||||
assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! `pdfcraft-cli ui --control FILE`: commands go only to an app whose control file is plausibly
|
||||
//! the user's own. Another local user can create a file at a shared path such as `/tmp/pc.json`
|
||||
//! first, naming their own port and token; the CLI must not then send them typed text or trust
|
||||
//! their replies.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// A folder for one test, removed when the test ends.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl std::ops::Deref for Scratch {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(test: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-ui-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
|
||||
/// A stand-in for the app's control listener that records everything a client sends.
|
||||
struct Spy {
|
||||
port: u16,
|
||||
seen: JoinHandle<String>,
|
||||
}
|
||||
|
||||
fn spy() -> Spy {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let seen = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut write = stream.try_clone().unwrap();
|
||||
let mut seen = String::new();
|
||||
for line in BufReader::new(stream).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let id = serde_json::from_str::<serde_json::Value>(&line).ok().and_then(|v| v.get("id").cloned()).unwrap_or_default();
|
||||
seen.push_str(&line);
|
||||
seen.push('\n');
|
||||
if writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": { "ok": true } })).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
seen
|
||||
});
|
||||
Spy { port, seen }
|
||||
}
|
||||
|
||||
impl Spy {
|
||||
/// Everything the client sent. Connects once itself, so the listener wakes up when the client
|
||||
/// never connected; that connection sends nothing.
|
||||
fn received(self) -> String {
|
||||
let _ = TcpStream::connect(("127.0.0.1", self.port));
|
||||
self.seen.join().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn control_json(port: u16) -> String {
|
||||
serde_json::json!({ "port": port, "token": TOKEN, "pid": 1 }).to_string()
|
||||
}
|
||||
|
||||
/// Write a control file the way the app does: readable by the owner only.
|
||||
fn write_private(path: &Path, text: &str) {
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn ui(control: &Path, method: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).arg("ui").arg("--control").arg(control).arg(method).output().unwrap()
|
||||
}
|
||||
|
||||
fn assert_refused(o: &Output, received: &str, expect: &str) {
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!received.contains(TOKEN), "the token was sent: {received}");
|
||||
assert!(!o.status.success(), "expected a refusal; stderr: {stderr}");
|
||||
assert!(stderr.contains(expect), "expected {expect:?} in: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_file_of_your_own_still_works() {
|
||||
let dir = scratch("own");
|
||||
let spy = spy();
|
||||
let control = dir.join("pc.json");
|
||||
write_private(&control, &control_json(spy.port));
|
||||
let o = ui(&control, "state");
|
||||
let received = spy.received();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(received.contains(TOKEN) && received.contains("ui.state"), "{received}");
|
||||
}
|
||||
|
||||
/// Checking the owner must not depend on anything but the control file: not on being able to
|
||||
/// write the temp folder (a sandbox, a read-only container, an odd `TMPDIR`).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_control_file_of_your_own_works_without_a_writable_temp_folder() {
|
||||
let dir = scratch("no-tmp");
|
||||
let spy = spy();
|
||||
let control = dir.join("pc.json");
|
||||
write_private(&control, &control_json(spy.port));
|
||||
let missing = dir.join("no-such-temp-folder");
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli"))
|
||||
.env("TMPDIR", &missing)
|
||||
.arg("ui")
|
||||
.arg("--control")
|
||||
.arg(&control)
|
||||
.arg("state")
|
||||
.output()
|
||||
.unwrap();
|
||||
let received = spy.received();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(received.contains(TOKEN) && received.contains("ui.state"), "{received}");
|
||||
assert!(!missing.exists(), "the CLI created the temp folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symlinked_control_file_is_refused_before_connecting() {
|
||||
let dir = scratch("symlink");
|
||||
let spy = spy();
|
||||
let target = dir.join("theirs.json");
|
||||
write_private(&target, &control_json(spy.port));
|
||||
let control = dir.join("pc.json");
|
||||
#[cfg(unix)]
|
||||
let linked = std::os::unix::fs::symlink(&target, &control);
|
||||
#[cfg(windows)]
|
||||
let linked = std::os::windows::fs::symlink_file(&target, &control);
|
||||
if let Err(e) = linked {
|
||||
eprintln!("skipped: can't create a symlink here ({e})");
|
||||
drop(spy.received());
|
||||
return;
|
||||
}
|
||||
let o = ui(&control, "type");
|
||||
assert_refused(&o, &spy.received(), "symbolic link");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_control_file_is_refused() {
|
||||
let dir = scratch("oversized");
|
||||
let spy = spy();
|
||||
let control = dir.join("pc.json");
|
||||
// Valid JSON, padded far beyond anything the app writes.
|
||||
write_private(&control, &format!("{}{}", control_json(spy.port), " ".repeat(1 << 20)));
|
||||
let o = ui(&control, "state");
|
||||
assert_refused(&o, &spy.received(), "too large");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_port_beyond_65535_is_refused_not_wrapped() {
|
||||
let dir = scratch("port");
|
||||
let spy = spy();
|
||||
let control = dir.join("pc.json");
|
||||
// 65536 more than the spy's port: truncated to 16 bits, it would reach the spy.
|
||||
let json = serde_json::json!({ "port": u64::from(spy.port) + 65536, "token": TOKEN, "pid": 1 }).to_string();
|
||||
write_private(&control, &json);
|
||||
let o = ui(&control, "state");
|
||||
assert_refused(&o, &spy.received(), "not a port number");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_control_file_other_users_can_read_or_write_is_refused() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = scratch("mode");
|
||||
for mode in [0o644, 0o640, 0o602, 0o620] {
|
||||
let spy = spy();
|
||||
let control = dir.join(format!("pc-{mode:o}.json"));
|
||||
write_private(&control, &control_json(spy.port));
|
||||
std::fs::set_permissions(&control, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||
let o = ui(&control, "state");
|
||||
assert_refused(&o, &spy.received(), "other users");
|
||||
}
|
||||
}
|
||||
|
||||
/// Needs root to give a file to another user, so it checks something only where the tests run as
|
||||
/// root, such as a root container. GitHub's Linux and macOS runners don't, so there the ownership
|
||||
/// rule is covered by the unit test alone.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_control_file_owned_by_another_user_is_refused() {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let dir = scratch("owner");
|
||||
let spy = spy();
|
||||
let control = dir.join("pc.json");
|
||||
write_private(&control, &control_json(spy.port));
|
||||
if std::fs::metadata(&control).unwrap().uid() != 0 {
|
||||
eprintln!("skipped: needs root to create a file owned by another user");
|
||||
drop(spy.received());
|
||||
return;
|
||||
}
|
||||
std::os::unix::fs::chown(&control, Some(65534), None).unwrap();
|
||||
let o = ui(&control, "state");
|
||||
assert_refused(&o, &spy.received(), "owned by another user");
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>`
|
||||
//!
|
||||
//! `--control <file>` enables the UI control channel (off by default): the app listens on a random
|
||||
//! loopback port and writes `{"port", "token", "pid"}` to `<file>` (owner-only permissions).
|
||||
//! loopback port and writes `{"port", "token", "pid"}` to `<file>` (owner-only permissions). The
|
||||
//! app doesn't start if it can't.
|
||||
//! Agents then drive it with `pdfcraft-cli ui --control <file> <method> …`.
|
||||
|
||||
// Release builds on Windows are GUI-subsystem programs, so launching the app doesn't open a console
|
||||
@@ -287,11 +288,7 @@ fn app_creator<'a>(
|
||||
}
|
||||
if let Some(file) = &control_file {
|
||||
let client = app.attach_control(&cc.egui_ctx);
|
||||
match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
|
||||
// Never the token (AGENTS.md §3): it stays in the owner-only file.
|
||||
Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
|
||||
Err(e) => log::error!("--control {file}: {e}"),
|
||||
}
|
||||
open_control_channel(file, pdfcraft_ui_egui::control::serve(client))?;
|
||||
}
|
||||
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
|
||||
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
|
||||
@@ -338,6 +335,27 @@ fn notify_software_renderer(app: &mut PdfCraftApp, device_type: eframe::wgpu::De
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a started control channel in `file`.
|
||||
///
|
||||
/// A failure stops the app. `--control` was asked for, so carrying on without it would leave a
|
||||
/// script driving nothing, and the file that couldn't be replaced may be another user's, planted
|
||||
/// at a shared path such as `/tmp/pc.json` to receive the commands. This runs before any document
|
||||
/// opens, so nothing is lost.
|
||||
fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::control::Endpoint>) -> Result<(), String> {
|
||||
match endpoint.and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
|
||||
// Never the token (AGENTS.md §3): it stays in the owner-only file.
|
||||
Ok(port) => {
|
||||
log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json");
|
||||
log::error!("{message}");
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the control endpoint so that only the current user can read the token.
|
||||
///
|
||||
/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
|
||||
@@ -527,6 +545,34 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_ui_egui::control::Endpoint;
|
||||
|
||||
fn endpoint() -> std::io::Result<Endpoint> {
|
||||
Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_channel_that_cant_be_published_stops_the_app() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |name: &str| dir.join(name).to_str().unwrap().to_owned();
|
||||
|
||||
// A folder in the way stands in for a file another user created first: the write fails.
|
||||
std::fs::create_dir(dir.join("taken.json")).unwrap();
|
||||
let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err();
|
||||
assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}");
|
||||
// No listener: nothing to publish.
|
||||
let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err();
|
||||
assert!(err.contains("no loopback"), "{err}");
|
||||
assert!(!dir.join("pc.json").exists());
|
||||
|
||||
super::open_control_channel(&path("pc.json"), endpoint()).unwrap();
|
||||
let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap();
|
||||
assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_from_arg_decodes_file_uris() {
|
||||
assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
|
||||
|
||||
Reference in new issue
Block a user