use crate::{default_socket_path, JsonRpcVersion, Request, RpcError};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(unix)]
use std::io::{BufRead, BufReader, Read as _, Write};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
pub const READ_TIMEOUT: Duration = Duration::from_secs(5);
pub const WRITE_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
const REQUEST_ID: u64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnavailableKind {
NotFound,
ConnectionRefused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum EnvelopeError {
#[error("missing or unsupported `jsonrpc` version (expected \"2.0\")")]
WrongVersion,
#[error("missing or non-integer `id`")]
MissingId,
#[error("both `result` and `error` present")]
BothResultAndError,
#[error("neither `result` nor `error` present")]
NeitherResultNorError,
#[error("`error` member is not a valid JSON-RPC error object")]
InvalidErrorObject,
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("no Scrybe running (socket {}: {kind:?})", path.display())]
SocketUnavailable {
path: PathBuf,
kind: UnavailableKind,
},
#[error("permission denied connecting to Scrybe socket {}", path.display())]
PermissionDenied {
path: PathBuf,
},
#[error("timed out connecting to the Scrybe socket")]
ConnectTimeout,
#[error("timed out writing request to the Scrybe socket")]
WriteTimeout,
#[error("timed out waiting for a reply from the Scrybe app")]
ReadTimeout,
#[error("socket I/O error: {0}")]
Io(#[source] std::io::Error),
#[error("reply frame too large: read {bytes} bytes, limit {limit}")]
FrameTooLarge {
bytes: usize,
limit: usize,
},
#[error("reply is not valid UTF-8")]
InvalidUtf8,
#[error("reply is not valid JSON: {0}")]
InvalidJson(#[source] serde_json::Error),
#[error("reply violates the JSON-RPC envelope: {0}")]
InvalidEnvelope(#[from] EnvelopeError),
#[error("reply id {actual} does not match request id {expected}")]
MismatchedResponseId {
expected: u64,
actual: u64,
},
#[error("Scrybe app error {}: {}", .0.code, .0.message)]
Remote(RpcError),
}
impl ClientError {
pub fn is_not_running(&self) -> bool {
matches!(self, Self::SocketUnavailable { .. })
}
}
pub fn socket_path() -> PathBuf {
default_socket_path()
}
pub fn is_live() -> bool {
try_connect().is_ok()
}
#[cfg(unix)]
pub fn try_connect() -> Result<UnixStream, ClientError> {
try_connect_at(&default_socket_path())
}
#[cfg(unix)]
pub fn try_connect_at(path: &Path) -> Result<UnixStream, ClientError> {
if !path.exists() {
return Err(ClientError::SocketUnavailable {
path: path.to_path_buf(),
kind: UnavailableKind::NotFound,
});
}
match UnixStream::connect(path) {
Ok(s) => {
s.set_read_timeout(Some(READ_TIMEOUT))
.map_err(ClientError::Io)?;
s.set_write_timeout(Some(WRITE_TIMEOUT))
.map_err(ClientError::Io)?;
Ok(s)
}
Err(e) => Err(match e.kind() {
std::io::ErrorKind::NotFound => ClientError::SocketUnavailable {
path: path.to_path_buf(),
kind: UnavailableKind::NotFound,
},
std::io::ErrorKind::ConnectionRefused => ClientError::SocketUnavailable {
path: path.to_path_buf(),
kind: UnavailableKind::ConnectionRefused,
},
std::io::ErrorKind::PermissionDenied => ClientError::PermissionDenied {
path: path.to_path_buf(),
},
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => {
ClientError::ConnectTimeout
}
_ => ClientError::Io(e),
}),
}
}
#[cfg(not(unix))]
pub fn try_connect() -> Result<(), ClientError> {
try_connect_at(Path::new("(unsupported)"))
}
#[cfg(not(unix))]
pub fn try_connect_at(path: &Path) -> Result<(), ClientError> {
Err(unsupported(path))
}
#[cfg(not(unix))]
fn unsupported(path: &Path) -> ClientError {
ClientError::SocketUnavailable {
path: path.to_path_buf(),
kind: UnavailableKind::NotFound,
}
}
pub fn send(method: &str, params: serde_json::Value) -> Result<serde_json::Value, ClientError> {
send_to(&default_socket_path(), method, params)
}
#[cfg(unix)]
pub fn send_to(
socket: &Path,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, ClientError> {
let mut stream = try_connect_at(socket)?;
let req = Request {
jsonrpc: JsonRpcVersion,
id: REQUEST_ID,
method: method.to_string(),
params,
};
let line = serde_json::to_string(&req)
.map_err(|e| ClientError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
writeln!(stream, "{line}").map_err(write_error)?;
let raw = read_frame(stream)?;
validate_envelope(&raw, REQUEST_ID)
}
#[cfg(not(unix))]
pub fn send_to(
socket: &Path,
_method: &str,
_params: serde_json::Value,
) -> Result<serde_json::Value, ClientError> {
Err(unsupported(socket))
}
#[cfg(unix)]
fn read_frame(stream: UnixStream) -> Result<Vec<u8>, ClientError> {
let mut reader = BufReader::new(stream).take(MAX_FRAME_BYTES as u64 + 1);
let mut buf = Vec::new();
reader.read_until(b'\n', &mut buf).map_err(read_error)?;
if buf.len() > MAX_FRAME_BYTES {
return Err(ClientError::FrameTooLarge {
bytes: buf.len(),
limit: MAX_FRAME_BYTES,
});
}
if buf.is_empty() {
return Err(ClientError::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"server closed connection without responding",
)));
}
if buf.last() != Some(&b'\n') {
return Err(ClientError::Io(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"connection closed mid-frame",
)));
}
Ok(buf)
}
fn write_error(e: std::io::Error) -> ClientError {
match e.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ClientError::WriteTimeout,
_ => ClientError::Io(e),
}
}
fn read_error(e: std::io::Error) -> ClientError {
match e.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => ClientError::ReadTimeout,
_ => ClientError::Io(e),
}
}
fn validate_envelope(frame: &[u8], expected_id: u64) -> Result<serde_json::Value, ClientError> {
let text = std::str::from_utf8(frame).map_err(|_| ClientError::InvalidUtf8)?;
let raw: serde_json::Value =
serde_json::from_str(text.trim_end()).map_err(ClientError::InvalidJson)?;
match raw.get("jsonrpc").and_then(serde_json::Value::as_str) {
Some("2.0") => {}
_ => return Err(EnvelopeError::WrongVersion.into()),
}
let actual = raw
.get("id")
.and_then(serde_json::Value::as_u64)
.ok_or(EnvelopeError::MissingId)?;
if actual != expected_id {
return Err(ClientError::MismatchedResponseId {
expected: expected_id,
actual,
});
}
match (raw.get("result"), raw.get("error")) {
(Some(_), Some(_)) => Err(EnvelopeError::BothResultAndError.into()),
(None, None) => Err(EnvelopeError::NeitherResultNorError.into()),
(Some(result), None) => Ok(result.clone()),
(None, Some(error)) => {
let rpc: RpcError = serde_json::from_value(error.clone())
.map_err(|_| EnvelopeError::InvalidErrorObject)?;
Err(ClientError::Remote(rpc))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ERR_METHOD_NOT_FOUND;
#[test]
fn socket_path_exposed() {
let p = socket_path();
assert!(!p.as_os_str().is_empty());
}
#[test]
fn request_serializes_to_single_line() {
let req = Request {
jsonrpc: JsonRpcVersion,
id: 1,
method: "open".into(),
params: serde_json::json!({"path": "/tmp/foo.md"}),
};
let s = serde_json::to_string(&req).unwrap();
assert!(!s.contains('\n'));
}
#[test]
fn valid_result_envelope_unwraps() {
let frame = br#"{"jsonrpc":"2.0","id":1,"result":{"applied":true}}
"#;
let v = validate_envelope(frame, 1).unwrap();
assert_eq!(v["applied"], true);
}
#[test]
fn remote_error_envelope_is_typed() {
let frame = format!(
"{{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{{\"code\":{ERR_METHOD_NOT_FOUND},\"message\":\"x\"}}}}\n",
);
let err = validate_envelope(frame.as_bytes(), 1).unwrap_err();
match err {
ClientError::Remote(e) => assert_eq!(e.code, ERR_METHOD_NOT_FOUND),
other => panic!("expected Remote, got {other:?}"),
}
}
#[test]
fn wrong_version_is_envelope_error() {
let frame = br#"{"jsonrpc":"1.0","id":1,"result":{}}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::WrongVersion)
));
}
#[test]
fn missing_version_is_envelope_error() {
let frame = br#"{"id":1,"result":{}}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::WrongVersion)
));
}
#[test]
fn missing_id_is_envelope_error() {
let frame = br#"{"jsonrpc":"2.0","result":{}}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::MissingId)
));
}
#[test]
fn mismatched_id_carries_both_ids() {
let frame = br#"{"jsonrpc":"2.0","id":7,"result":{}}"#;
let err = validate_envelope(frame, 1).unwrap_err();
match err {
ClientError::MismatchedResponseId { expected, actual } => {
assert_eq!(expected, 1);
assert_eq!(actual, 7);
}
other => panic!("expected MismatchedResponseId, got {other:?}"),
}
}
#[test]
fn both_result_and_error_is_envelope_error() {
let frame =
br#"{"jsonrpc":"2.0","id":1,"result":{},"error":{"code":-32000,"message":"x"}}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::BothResultAndError)
));
}
#[test]
fn neither_result_nor_error_is_envelope_error() {
let frame = br#"{"jsonrpc":"2.0","id":1}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::NeitherResultNorError)
));
}
#[test]
fn malformed_error_object_is_envelope_error() {
let frame = br#"{"jsonrpc":"2.0","id":1,"error":"boom"}"#;
let err = validate_envelope(frame, 1).unwrap_err();
assert!(matches!(
err,
ClientError::InvalidEnvelope(EnvelopeError::InvalidErrorObject)
));
}
#[test]
fn invalid_utf8_is_typed() {
let err = validate_envelope(&[0xff, 0xfe, b'\n'], 1).unwrap_err();
assert!(matches!(err, ClientError::InvalidUtf8));
}
#[test]
fn invalid_json_is_typed() {
let err = validate_envelope(b"{not json\n", 1).unwrap_err();
assert!(matches!(err, ClientError::InvalidJson(_)));
}
#[cfg(unix)]
#[test]
fn try_connect_at_types_missing_socket_as_not_running() {
let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-unit-test");
let err = try_connect_at(&path).unwrap_err();
assert!(err.is_not_running());
assert!(matches!(
err,
ClientError::SocketUnavailable {
kind: UnavailableKind::NotFound,
..
}
));
}
#[cfg(unix)]
#[test]
fn send_to_types_missing_socket_as_not_running() {
let path = std::path::PathBuf::from("/tmp/scrybe-nonexistent-sock-rpc-send-test");
let err = send_to(&path, "open", serde_json::json!({"path": "/tmp/foo.md"})).unwrap_err();
assert!(err.is_not_running(), "actual: {err:?}");
}
#[cfg(unix)]
#[test]
fn is_live_false_without_server() {
let _ = is_live();
}
}