Files
pdfcraft/apps/pdfcraft-cli/src/ui.rs
T

189 lines
11 KiB
Rust

use super::options::{UI_OPTIONS, flag, positional, validate_options};
use super::{CliError, Duration, stdout_line};
// ---- 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;
#[cfg(not(windows))]
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`";
// cmd.exe doesn't expand `~` (#3922).
#[cfg(windows)]
const CONTROL_FILE_ADVICE: &str =
r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.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 a file owned by someone else or
/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
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}"));
}
#[cfg(not(windows))]
let text = {
use std::io::Read as _;
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}"))?;
text
};
// Windows: the same owner and access check, on the security descriptor of the very handle the
// contents are read through, so nothing swapped in at the path gets through (#3922). A file
// planted by another user, or one other users can read, is refused; if the check itself can't
// run, the file isn't trusted either.
#[cfg(windows)]
let text = {
use pdfcraft_platform::owner_only;
let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
.map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
String::from_utf8(bytes).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;
/// #3922: on Windows the CLI checks the control file's owner and access list. A file other
/// users can read is refused; the same file restricted to its owner is accepted.
#[cfg(windows)]
#[test]
fn on_windows_a_control_file_other_users_can_read_is_refused() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
let file = dir.join("ctl.json");
std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
let path = file.to_string_lossy().into_owned();
let refused = super::read_control_file(&path).unwrap_err();
assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
// The contents come with the check, capped: exactly the limit is read, one byte more is refused.
let padded = |len: usize| {
let json = r#"{"port":4242,"token":"t0ken"}"#;
json.to_string() + &" ".repeat(len - json.len())
};
std::fs::write(&file, padded(64 * 1024)).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
// Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
assert!(super::read_control_file(&path).is_err(), "not UTF-8");
let _ = std::fs::remove_dir_all(&dir);
}
#[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"));
}
}