mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
502 lines
23 KiB
Rust
502 lines
23 KiB
Rust
//! Where MCP tool calls end up: a control-channel method call.
|
|
|
|
use std::io::{BufReader, Write};
|
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::server::{BoundedLine, read_bounded_line};
|
|
|
|
/// Longest reply line accepted from the app (screenshots and `document.inspect` are large).
|
|
const MAX_REPLY_LINE: usize = 256 << 20;
|
|
|
|
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
|
|
/// `ui.pointer`, `ui.render`, …). See `wordcraft_ui_egui::control` for the full list.
|
|
pub trait Backend {
|
|
/// Call one method. `Ok` carries the `result`, `Err` the error message.
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
|
/// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work).
|
|
fn has_ui(&self) -> bool;
|
|
/// Short human description ("headless", "connected to 127.0.0.1:7981").
|
|
fn describe(&self) -> String;
|
|
}
|
|
|
|
/// Looks up the control key for an address (`HOST:PORT`).
|
|
type KeyFinder = Box<dyn Fn(&str) -> Result<String, String> + Send>;
|
|
|
|
/// A running WordCraft app, reached through its loopback control port.
|
|
pub struct Remote {
|
|
addr: String,
|
|
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
|
next_id: u64,
|
|
/// Whether a connection ever succeeded (`lazy` starts without one; the app may open later).
|
|
connected: bool,
|
|
/// The connection leads to the app's 127.0.0.1: requests carry the control key.
|
|
send_key: bool,
|
|
/// The control key, looked up once per connection (the app picks a new one at every start).
|
|
key: Option<String>,
|
|
find_key: KeyFinder,
|
|
}
|
|
|
|
/// The control key: `env` (`WORDCRAFT_CONTROL_KEY`) when set, else the app's key file for
|
|
/// `port` in `settings`. The error says where it looked.
|
|
fn lookup_key(env: Option<String>, settings: Option<&Path>, port: Option<u16>) -> Result<String, String> {
|
|
if let Some(k) = env.map(|k| k.trim().to_string()).filter(|k| !k.is_empty()) {
|
|
return Ok(k);
|
|
}
|
|
let (Some(settings), Some(port)) = (settings, port) else {
|
|
return Err("no control key: set WORDCRAFT_CONTROL_KEY (no settings folder or port to find the key file)".to_string());
|
|
};
|
|
let path = wordcraft_control_key::key_path(settings, port);
|
|
match std::fs::read_to_string(&path).map(|k| k.trim().to_string()) {
|
|
Ok(k) if !k.is_empty() => Ok(k),
|
|
_ => Err(format!("no control key: set WORDCRAFT_CONTROL_KEY, or start the app with --control {port} (it writes {})", path.display())),
|
|
}
|
|
}
|
|
|
|
/// The key for the app at `addr`, from the environment or the app's key file.
|
|
fn find_key(addr: &str) -> Result<String, String> {
|
|
let port = addr.rsplit_once(':').and_then(|(_, p)| p.parse().ok());
|
|
lookup_key(std::env::var("WORDCRAFT_CONTROL_KEY").ok(), wordcraft_control_key::settings_dir().as_deref(), port)
|
|
}
|
|
|
|
/// The only address the app's control server binds (`apps/wordcraft/src/control_server.rs`).
|
|
const APP_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
|
|
|
/// The host part of `addr` (`HOST:PORT`, `[V6]:PORT`), without brackets.
|
|
fn host_of(addr: &str) -> &str {
|
|
let host = addr.rsplit_once(':').map_or(addr, |(h, _)| h);
|
|
host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host)
|
|
}
|
|
|
|
/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names the app's own address:
|
|
/// 127.0.0.1 or `localhost`. Other loopback addresses (127.0.0.2, ::1) don't count: the app never
|
|
/// listens there, so whatever does is not the app. Other host names are not resolved.
|
|
fn is_app_addr(addr: &str) -> bool {
|
|
let app = |ip: IpAddr| ip.to_canonical() == APP_IP;
|
|
if let Ok(sa) = addr.parse::<SocketAddr>() {
|
|
return app(sa.ip());
|
|
}
|
|
if let Ok(ip) = addr.parse::<IpAddr>() {
|
|
return app(ip);
|
|
}
|
|
let host = host_of(addr);
|
|
host.parse::<IpAddr>().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), app)
|
|
}
|
|
|
|
/// The key goes only to the app's `--connect` address, and only when the connection really
|
|
/// leads to 127.0.0.1 (`peer`): a listener on another loopback address (::1, 127.0.0.2), which
|
|
/// any local user can open, never sees it.
|
|
fn may_send_key(addr: &str, peer: Option<SocketAddr>) -> bool {
|
|
is_app_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical() == APP_IP)
|
|
}
|
|
|
|
/// Where to connect for `addr`: `localhost` means the app's 127.0.0.1 (not whatever the resolver
|
|
/// lists first, which may be ::1); anything else as resolved.
|
|
fn targets(addr: &str) -> std::io::Result<Vec<SocketAddr>> {
|
|
if host_of(addr).eq_ignore_ascii_case("localhost")
|
|
&& let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok())
|
|
{
|
|
return Ok(vec![SocketAddr::new(APP_IP, port)]);
|
|
}
|
|
Ok(addr.to_socket_addrs()?.collect())
|
|
}
|
|
|
|
impl Remote {
|
|
/// Connect to `addr` (`127.0.0.1:7981`), failing fast when nothing is listening.
|
|
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
|
Self::connect_with(addr, Box::new(find_key))
|
|
}
|
|
|
|
fn connect_with(addr: &str, find_key: KeyFinder) -> std::io::Result<Self> {
|
|
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key };
|
|
r.reconnect()?;
|
|
if let Some(w) = r.key_warning() {
|
|
log::warn!("{w}");
|
|
}
|
|
Ok(r)
|
|
}
|
|
|
|
/// A remote that connects on first use: the app may start after the MCP server, and a
|
|
/// server that exits at once reads as failed for the client's whole session (#4078).
|
|
pub fn lazy(addr: &str) -> Self {
|
|
Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) }
|
|
}
|
|
|
|
/// The address the app is reached at, as given.
|
|
pub fn addr(&self) -> &str {
|
|
&self.addr
|
|
}
|
|
|
|
/// Why requests go without the control key (the address is not the app's 127.0.0.1), if they
|
|
/// do.
|
|
pub fn key_warning(&self) -> Option<String> {
|
|
// Nothing is known about the address until a connection succeeds.
|
|
if !self.connected {
|
|
return None;
|
|
}
|
|
(!self.send_key).then(|| {
|
|
format!(
|
|
"{} is not the app's address, so the control key is not sent (it only goes to 127.0.0.1 and localhost, where the app listens) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel",
|
|
self.addr
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Drop the connection and connect again to the first of the address's targets that answers,
|
|
/// deciding afresh whether the control key may go to it.
|
|
fn reconnect(&mut self) -> std::io::Result<()> {
|
|
self.conn = None;
|
|
self.key = None;
|
|
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
|
for sa in targets(&self.addr)? {
|
|
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
|
Ok(s) => {
|
|
s.set_nodelay(true).ok();
|
|
// The app answers within 60 s (its own timeout); leave headroom.
|
|
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
|
|
self.send_key = may_send_key(&self.addr, s.peer_addr().ok());
|
|
let read = s.try_clone()?;
|
|
self.conn = Some((BufReader::new(read), s));
|
|
self.connected = true;
|
|
return Ok(());
|
|
}
|
|
Err(e) => last = e,
|
|
}
|
|
}
|
|
Err(last)
|
|
}
|
|
|
|
/// The request line; it carries the key when this connection may (looked up once per
|
|
/// connection).
|
|
fn request_line(&mut self, id: u64, method: &str, params: &Value) -> Result<String, String> {
|
|
let mut req = json!({"id": id, "method": method, "params": params});
|
|
if self.send_key {
|
|
let key = match &self.key {
|
|
Some(k) => k.clone(),
|
|
None => (self.find_key)(&self.addr)?,
|
|
};
|
|
req["key"] = json!(key);
|
|
self.key = Some(key);
|
|
}
|
|
Ok(req.to_string())
|
|
}
|
|
|
|
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
|
let Some((reader, writer)) = self.conn.as_mut() else {
|
|
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
|
};
|
|
writer.write_all(line.as_bytes())?;
|
|
writer.write_all(b"\n")?;
|
|
writer.flush()?;
|
|
match read_bounded_line(reader, MAX_REPLY_LINE)? {
|
|
BoundedLine::Line(reply) => Ok(reply),
|
|
BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")),
|
|
BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")),
|
|
}
|
|
}
|
|
|
|
/// One request and its reply, with one retry on a new connection (the app may have
|
|
/// restarted; a new connection also reads the key again).
|
|
fn exchange(&mut self, id: u64, method: &str, params: &Value) -> Result<Value, String> {
|
|
let unreachable = |addr: &str, e: std::io::Error, connected: bool| {
|
|
let retry = if connected { String::new() } else { "; will retry on each call".to_string() };
|
|
format!("WordCraft app at {addr} is not reachable: {e}{retry}")
|
|
};
|
|
let mut first = true;
|
|
let reply = loop {
|
|
let tried = if self.conn.is_some() { Ok(()) } else { self.reconnect() };
|
|
let result = match tried {
|
|
Ok(()) => {
|
|
let line = self.request_line(id, method, params)?;
|
|
self.roundtrip(&line)
|
|
}
|
|
Err(e) => Err(e),
|
|
};
|
|
match result {
|
|
Ok(r) => break r,
|
|
Err(e) => {
|
|
self.conn = None;
|
|
// A reply that arrived but can't be read (too long, not UTF-8): the app has
|
|
// run the request, so sending it again could run a state-changing one twice.
|
|
if e.kind() == std::io::ErrorKind::InvalidData {
|
|
return Err(format!("bad reply from app: {e}"));
|
|
}
|
|
if !first {
|
|
return Err(unreachable(&self.addr, e, self.connected));
|
|
}
|
|
first = false;
|
|
}
|
|
}
|
|
};
|
|
serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))
|
|
}
|
|
}
|
|
|
|
impl Backend for Remote {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let unauthorized = |v: &Value| v.get("error").and_then(Value::as_str) == Some("unauthorized");
|
|
let mut v = self.exchange(id, method, ¶ms)?;
|
|
if unauthorized(&v) && self.send_key {
|
|
// A stale key (the app restarted on this port): the app closed the connection; a new
|
|
// one reads the key again. Once.
|
|
self.conn = None;
|
|
v = self.exchange(id, method, ¶ms)?;
|
|
}
|
|
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
|
} else if unauthorized(&v) {
|
|
self.conn = None;
|
|
Err(self.key_warning().map_or_else(
|
|
|| {
|
|
format!(
|
|
"unauthorized: the WordCraft app at {} refused the control key (from WORDCRAFT_CONTROL_KEY or the app's key file)",
|
|
self.addr
|
|
)
|
|
},
|
|
|w| format!("unauthorized: {w}"),
|
|
))
|
|
} else {
|
|
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
|
}
|
|
}
|
|
|
|
fn has_ui(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn describe(&self) -> String {
|
|
if self.connected {
|
|
format!("connected to the WordCraft app at {}", self.addr)
|
|
} else {
|
|
format!("will connect to the WordCraft app at {} when it opens", self.addr)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::BufRead;
|
|
use std::net::{SocketAddr, TcpListener};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::mpsc::{Receiver, channel};
|
|
|
|
#[test]
|
|
fn key_from_env_then_key_file() {
|
|
let dir = std::env::temp_dir().join(format!("wordcraft-mcp-key-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = wordcraft_control_key::key_path(&dir, 7981);
|
|
// Neither: the error names the variable and the file it looked for.
|
|
let e = lookup_key(None, Some(&dir), Some(7981)).unwrap_err();
|
|
assert!(e.contains("WORDCRAFT_CONTROL_KEY") && e.contains(&path.display().to_string()), "{e}");
|
|
// The key file, trimmed; an empty variable does not count.
|
|
std::fs::write(&path, " file-key\n").unwrap();
|
|
assert_eq!(lookup_key(None, Some(&dir), Some(7981)).unwrap(), "file-key");
|
|
assert_eq!(lookup_key(Some(" ".into()), Some(&dir), Some(7981)).unwrap(), "file-key");
|
|
// The variable wins.
|
|
assert_eq!(lookup_key(Some(" env-key ".into()), Some(&dir), Some(7981)).unwrap(), "env-key");
|
|
assert_eq!(lookup_key(Some("env-key".into()), None, None).unwrap(), "env-key");
|
|
// Read again every time: the app writes a new key at every start.
|
|
std::fs::write(&path, "new-key").unwrap();
|
|
assert_eq!(lookup_key(None, Some(&dir), Some(7981)).unwrap(), "new-key");
|
|
// Another port, another file.
|
|
assert!(lookup_key(None, Some(&dir), Some(7982)).is_err());
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn key_only_for_the_apps_address() {
|
|
let peer = |s: &str| s.parse::<SocketAddr>().ok();
|
|
for (addr, at) in [
|
|
("127.0.0.1:7981", "127.0.0.1:7981"),
|
|
("[::ffff:127.0.0.1]:7981", "[::ffff:127.0.0.1]:7981"),
|
|
("localhost:7981", "127.0.0.1:7981"),
|
|
("LocalHost:7981", "127.0.0.1:7981"),
|
|
] {
|
|
assert!(may_send_key(addr, peer(at)), "{addr} at {at}");
|
|
}
|
|
for (addr, at) in [
|
|
("192.0.2.20:7981", "192.0.2.20:7981"),
|
|
("10.0.0.7:7981", "10.0.0.7:7981"),
|
|
("[fe80::1]:7981", "[fe80::1]:7981"),
|
|
("0.0.0.0:7981", "127.0.0.1:7981"),
|
|
// Loopback addresses the app never binds: another local user's listener (#3699).
|
|
("127.0.0.2:7981", "127.0.0.2:7981"),
|
|
("[::1]:7981", "[::1]:7981"),
|
|
("localhost:7981", "[::1]:7981"),
|
|
("127.0.0.1:7981", "127.0.0.2:7981"),
|
|
// Host names other than localhost: even when they lead to this machine.
|
|
("workstation:7981", "127.0.1.1:7981"),
|
|
("example.org:7981", "93.184.215.14:7981"),
|
|
("localhost.example.org:7981", "127.0.0.1:7981"),
|
|
// `localhost` that does not lead to a loopback address.
|
|
("localhost:7981", "192.0.2.20:7981"),
|
|
] {
|
|
assert!(!may_send_key(addr, peer(at)), "{addr} at {at}");
|
|
}
|
|
// No peer address: no key.
|
|
assert!(!may_send_key("127.0.0.1:7981", None));
|
|
}
|
|
|
|
#[test]
|
|
fn localhost_connects_to_the_apps_address_only() {
|
|
// Not the resolver's list, which may put ::1 (where anyone can listen) first.
|
|
let at: SocketAddr = "127.0.0.1:7981".parse().unwrap();
|
|
assert_eq!(targets("localhost:7981").unwrap(), vec![at]);
|
|
assert_eq!(targets("LOCALHOST:7981").unwrap(), vec![at]);
|
|
assert_eq!(targets("127.0.0.1:7981").unwrap(), vec![at]);
|
|
assert_eq!(targets("[::1]:7981").unwrap(), vec!["[::1]:7981".parse::<SocketAddr>().unwrap()]);
|
|
}
|
|
|
|
/// A stand-in for the app on 127.0.0.1: every request line goes to the returned channel and
|
|
/// gets `answer(request)`: a reply and whether to close the connection after it, or `None`
|
|
/// to close it without a reply (the app quit).
|
|
fn fake_app(answer: impl Fn(&Value) -> Option<(Value, bool)> + Send + Sync + 'static) -> (u16, Receiver<Value>) {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let (tx, rx) = channel();
|
|
let answer = Arc::new(answer);
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming().flatten() {
|
|
let (tx, answer) = (tx.clone(), answer.clone());
|
|
std::thread::spawn(move || {
|
|
let mut out = stream.try_clone().unwrap();
|
|
for line in BufReader::new(stream).lines() {
|
|
let Ok(req) = serde_json::from_str::<Value>(&line.unwrap_or_default()) else { return };
|
|
let _ = tx.send(req.clone());
|
|
let Some((mut reply, close)) = answer(&req) else { return };
|
|
reply["id"] = req["id"].clone();
|
|
if writeln!(out, "{reply}").is_err() || close {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
(port, rx)
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreadable_reply_is_not_retried() {
|
|
// The app answered something that can't be read (here not UTF-8; an over-long reply
|
|
// takes the same path): the request ran, so it must not be sent again.
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let seen = Arc::new(AtomicUsize::new(0));
|
|
let n = seen.clone();
|
|
std::thread::spawn(move || {
|
|
for stream in listener.incoming().flatten() {
|
|
let n = n.clone();
|
|
std::thread::spawn(move || {
|
|
let mut out = stream.try_clone().unwrap();
|
|
for line in BufReader::new(stream).lines() {
|
|
if line.is_err() {
|
|
return;
|
|
}
|
|
n.fetch_add(1, Ordering::SeqCst);
|
|
if out.write_all(b"\xff\xfe\n").is_err() {
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k1")).unwrap();
|
|
let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err();
|
|
assert!(e.contains("bad reply"), "{e}");
|
|
assert_eq!(seen.load(Ordering::SeqCst), 1, "the request was sent once");
|
|
}
|
|
|
|
fn fixed_key(key: &'static str) -> KeyFinder {
|
|
Box::new(move |_| Ok(key.to_string()))
|
|
}
|
|
|
|
const WAIT: Duration = Duration::from_secs(5);
|
|
|
|
#[test]
|
|
fn sends_the_key_to_loopback() {
|
|
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 7}), false)));
|
|
for host in ["127.0.0.1", "localhost"] {
|
|
let mut r = Remote::connect_with(&format!("{host}:{port}"), fixed_key("k1")).unwrap();
|
|
assert!(r.key_warning().is_none(), "{host}");
|
|
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));
|
|
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "k1", "{host}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_lazy_remote_serves_once_the_app_opens() {
|
|
// Nothing is listening: the call fails, but the server stays up and says it will retry.
|
|
let mut r = Remote::lazy("127.0.0.1:1");
|
|
r.find_key = fixed_key("k1");
|
|
let e = r.call("document.inspect", json!({})).unwrap_err();
|
|
assert!(e.contains("not reachable") && e.contains("will retry"), "{e}");
|
|
assert!(r.describe().contains("will connect to"));
|
|
// The app opens later: the next call connects and answers.
|
|
let (port, seen) = fake_app(|_| Some((json!({"ok": true, "result": 7}), false)));
|
|
r.addr = format!("127.0.0.1:{port}");
|
|
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(7));
|
|
assert!(r.key_warning().is_none());
|
|
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "k1");
|
|
assert!(r.describe().contains("connected to"));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_the_key_again_on_a_new_connection() {
|
|
// The app restarted with a new key: the old key is refused (and the connection closed).
|
|
let (port, seen) = fake_app(|req| {
|
|
Some(if req["key"] == "new" { (json!({"ok": true, "result": 1}), false) } else { (json!({"ok": false, "error": "unauthorized"}), true) })
|
|
});
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let n = reads.clone();
|
|
let finder: KeyFinder = Box::new(move |_| Ok(if n.fetch_add(1, Ordering::SeqCst) == 0 { "old" } else { "new" }.to_string()));
|
|
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap();
|
|
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(1));
|
|
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "old");
|
|
assert_eq!(seen.recv_timeout(WAIT).unwrap()["key"], "new");
|
|
assert_eq!(reads.load(Ordering::SeqCst), 2);
|
|
// The new key is kept for the next requests on the same connection.
|
|
assert_eq!(r.call("document.inspect", json!({})).unwrap(), json!(1));
|
|
assert_eq!(reads.load(Ordering::SeqCst), 2);
|
|
|
|
// The app quits mid-session (connection closed): the retry reads the key again.
|
|
let (port, seen) = fake_app(|req| if req["id"] == 2 { None } else { Some((json!({"ok": true, "result": 1}), false)) });
|
|
let reads = Arc::new(AtomicUsize::new(0));
|
|
let n = reads.clone();
|
|
let finder: KeyFinder = Box::new(move |_| Ok(format!("key-{}", n.fetch_add(1, Ordering::SeqCst))));
|
|
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap();
|
|
r.call("document.inspect", json!({})).unwrap();
|
|
r.call("document.inspect", json!({})).unwrap_err();
|
|
let keys: Vec<Value> = seen.try_iter().map(|v| v["key"].clone()).collect();
|
|
assert_eq!(keys.first(), Some(&json!("key-0")), "{keys:?}");
|
|
assert_eq!(keys.last(), Some(&json!("key-1")), "{keys:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_key_for_other_hosts_and_a_clear_error() {
|
|
// There is no LAN address to test with here: same stand-in, with the decision a LAN
|
|
// address gets.
|
|
let (port, seen) = fake_app(|req| {
|
|
Some(if req.get("key").is_some() {
|
|
(json!({"ok": true, "result": 1}), false)
|
|
} else {
|
|
(json!({"ok": false, "error": "unauthorized"}), true)
|
|
})
|
|
});
|
|
let finder: KeyFinder = Box::new(|_| Err("the key must not be read for another host".to_string()));
|
|
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap();
|
|
r.send_key = false;
|
|
assert!(r.key_warning().is_some_and(|w| w.contains("not the app's address")));
|
|
let e = r.call("document.inspect", json!({})).unwrap_err();
|
|
assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}");
|
|
assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none());
|
|
}
|
|
}
|