mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
189 lines
11 KiB
Rust
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"));
|
|
}
|
|
}
|