Lines
53.23 %
Functions
16.67 %
Branches
100 %
//! `nms --ssh [USER@]HOST`: a thin client for the sshd `nomisync-eval`
//! subsystem. It shells out to the system `ssh`, so every part of
//! authentication — public key, ssh-agent, `~/.ssh/config`,
//! known_hosts, or an interactive password prompt — is handled by
//! OpenSSH. nms only frames forms and reads back response envelopes.
//!
//! The client therefore carries no `DATABASE_URL` and no Postgres
//! credentials: the SSH identity maps to a nomisync user server-side
//! (the daemon's `auth_publickey` / `auth_password`), and the eval runs
//! in that user's `rpc::Session`. The wire shape is exactly what
//! `nms --rpc-user` produces and `Session::handle_form` parses — a
//! newline-terminated `(:id N :form <form>)` request frame, with
//! balanced-paren response envelopes read back via [`rpc::FrameDecoder`].
//! Cooperative wire-interrupt (the `0x03` cancel byte the subsystem
//! understands) is intentionally not wired in this synchronous line
//! REPL — `eval` blocks awaiting the response, so there is no point at
//! which the client could read a `Ctrl-C` to forward it. A SIGINT
//! handler (or a TUI transport reusing this client) is the natural home
//! for that and can be added when there is a consumer.
use std::io::{Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use anyhow::{Context, Result, anyhow, bail};
use rpc::FrameDecoder;
/// The ssh subsystem name the daemon gates on.
const SUBSYSTEM: &str = "nomisync-eval";
/// A live connection to a remote `nomisync-eval` subsystem, backed by a
/// spawned `ssh` child process.
pub struct SshEval {
child: Child,
stdin: ChildStdin,
stdout: ChildStdout,
decoder: FrameDecoder,
next_id: u64,
}
impl SshEval {
/// Spawns `ssh [-p PORT] -s TARGET nomisync-eval` with piped
/// stdin/stdout (stderr is inherited so OpenSSH prompts and errors
/// reach the user's terminal). Authentication is entirely OpenSSH's
/// responsibility.
///
/// # Errors
/// Fails if `ssh` cannot be spawned (not installed / not on `PATH`)
/// or its pipes are unavailable.
pub fn connect(target: &str, port: Option<u16>) -> Result<Self> {
let mut command = Command::new("ssh");
command
.args(ssh_args(target, port))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = command
.spawn()
.context("failed to spawn `ssh` (is OpenSSH installed and on PATH?)")?;
let stdin = child.stdin.take().context("ssh stdin unavailable")?;
let stdout = child.stdout.take().context("ssh stdout unavailable")?;
Ok(Self {
child,
stdin,
stdout,
decoder: FrameDecoder::new(),
next_id: 1,
})
/// Wraps `form` in a fresh `(:id N :form …)` envelope, sends it, and
/// blocks until the matching response envelope arrives.
/// Fails on a write error or if the connection closes before a full
/// response frame is received.
pub fn eval(&mut self, form: &str) -> Result<String> {
write_frame(&mut self.stdin, self.next_id, form.trim())?;
self.next_id = self.next_id.wrapping_add(1);
read_frame(&mut self.decoder, &mut self.stdout)
impl Drop for SshEval {
fn drop(&mut self) {
// Reap the child so a dropped client never leaks a zombie ssh.
let _ = self.child.kill();
let _ = self.child.wait();
/// Builds the `ssh` argument vector. `-T` disables remote PTY
/// allocation: without it a user's `RequestTTY yes`/`force` ssh config
/// could make OpenSSH send a `pty-req` first, and the daemon routes a
/// PTY channel's bytes to its TUI handler (checked before the eval
/// channel), so eval requests would never reach the subsystem. `-s …
/// nomisync-eval` then requests the subsystem on the channel.
fn ssh_args(target: &str, port: Option<u16>) -> Vec<String> {
let mut args = Vec::new();
if let Some(port) = port {
args.push("-p".to_string());
args.push(port.to_string());
args.push("-T".to_string());
args.push("-s".to_string());
args.push(target.to_string());
args.push(SUBSYSTEM.to_string());
args
/// Renders the request frame the `nomisync-eval` subsystem expects: a
/// `(:id N :form <form>)` envelope, frame-terminated by a trailing
/// newline.
/// The closing paren sits on its own line: a trailing line-comment in
/// `form` (e.g. `(+ 1 2) ; note`) would otherwise comment out a
/// same-line terminator, leaving the server's `FrameDecoder` waiting on
/// an unbalanced frame forever (a client hang).
fn envelope(id: u64, form: &str) -> String {
format!("(:id {id} :form {form}\n)\n")
fn write_frame(writer: &mut impl Write, id: u64, form: &str) -> Result<()> {
writer.write_all(envelope(id, form).as_bytes())?;
writer.flush()?;
Ok(())
/// Pulls one complete response envelope from `reader`, feeding bytes
/// through the balanced-paren [`FrameDecoder`].
/// Fails if the connection closes before a full frame arrives or the
/// decoder rejects the bytes.
fn read_frame(decoder: &mut FrameDecoder, reader: &mut impl Read) -> Result<String> {
loop {
if let Some(frame) = decoder.next_frame() {
return frame.map_err(|err| anyhow!("malformed response frame: {err:?}"));
let mut buf = [0u8; 4096];
let read = reader.read(&mut buf)?;
if read == 0 {
bail!("ssh connection closed before a response was received");
decoder
.feed(&buf[..read])
.map_err(|err| anyhow!("malformed response frame: {err:?}"))?;
#[cfg(test)]
mod tests;