Files
pdfcraft/apps/pdfcraft-cli/src/ui.rs
T
aiongg 978bc28da8 refactor(cli): split main.rs into src/
New modules: options, commands, check, run, ui.
2026-10-26 22:18:50 +00:00

132 lines
7.3 KiB
Rust

use super::options::{UI_OPTIONS, flag, positional, validate_options};
use super::*;
// ---- UI control channel client -----------------------------------------------------------------
/// One request to a running app's control channel (`pdfcraft --control FILE`).
pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
validate_options("ui", args, UI_OPTIONS)?;
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 = 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, UI_OPTIONS);
let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?;
let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") };
let mut params = serde_json::Map::new();
for kv in pos.iter().skip(1) {
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)).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();
let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result<serde_json::Value, String> {
writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?;
let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?;
let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?;
match reply.get("error") {
Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()),
None => Ok(reply["result"].clone()),
}
};
rpc(1, "auth", serde_json::json!({ "token": token }))?;
let mut result = rpc(2, &method, serde_json::Value::Object(params))?;
if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) {
use base64::Engine as _;
let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?;
std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?;
result["png_base64"] = serde_json::json!(format!("written to {out}"));
}
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"));
}
}