feat(rust): add shared Signal client and workspace configuration
This commit is contained in:
1 parent
da1976f7ce
commit
df045d4c63
9 files changed
+508
-23
No files matched your search
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "signal"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Send Signal alerts through the local signal-cli JSON-RPC daemon"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Send alerts through the local signal-cli daemon on Unix systems.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use signal::Signal;
|
||||
//!
|
||||
//! let client = Signal {
|
||||
//! from_phone: "+15555550100".into(),
|
||||
//! };
|
||||
//! client.send("+15555550101", "Service failed")?;
|
||||
//! client.send("+15555550102", "Service recovered")?;
|
||||
//! # Ok::<(), anyhow::Error>(())
|
||||
//! ```
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
os::unix::net::UnixStream,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A sending account for alerts through `/run/signal-cli/socket`.
|
||||
pub struct Signal {
|
||||
/// The phone number of an account registered with the local daemon.
|
||||
pub from_phone: String,
|
||||
}
|
||||
|
||||
impl Signal {
|
||||
/// Send a message to `to_phone` and wait for the corresponding JSON-RPC response.
|
||||
///
|
||||
/// Socket reads and writes use a four-second timeout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if connecting, writing, or reading fails, the daemon
|
||||
/// returns invalid JSON or an RPC error, or it closes before responding.
|
||||
pub fn send(&self, to_phone: &str, body: &str) -> Result<()> {
|
||||
let mut stream = UnixStream::connect("/run/signal-cli/socket")
|
||||
.context("Failed to connect to Signal RPC socket")?;
|
||||
let timeout = Duration::from_secs(4);
|
||||
stream.set_read_timeout(Some(timeout))?;
|
||||
stream.set_write_timeout(Some(timeout))?;
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "send",
|
||||
"params": {
|
||||
"account": self.from_phone,
|
||||
"recipient": [to_phone],
|
||||
"message": body,
|
||||
},
|
||||
"id": "signal-alert",
|
||||
});
|
||||
writeln!(stream, "{request}").context("Failed to send Signal RPC request")?;
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if reader
|
||||
.read_line(&mut line)
|
||||
.context("Failed to read Signal RPC response")?
|
||||
== 0
|
||||
|| !line.ends_with('\n')
|
||||
{
|
||||
bail!("Signal RPC socket closed before returning a response");
|
||||
}
|
||||
let response: Value =
|
||||
serde_json::from_str(&line).context("Invalid Signal RPC response")?;
|
||||
if response.get("id").and_then(Value::as_str) != Some("signal-alert") {
|
||||
continue;
|
||||
}
|
||||
if let Some(error) = response.get("error").filter(|value| !value.is_null()) {
|
||||
bail!("Signal RPC returned an error: {error}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
#![cfg(test)]
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
os::{fd::AsRawFd, unix::net::UnixStream},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use signal::Signal;
|
||||
|
||||
thread_local! {
|
||||
static CONNECTION: RefCell<Option<UnixStream>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
// Patch libc's connect only in this integration-test executable. Each test
|
||||
// supplies its own socket pair; no call can reach the real Signal daemon.
|
||||
// SAFETY: This symbol uses libc's connect ABI and preserves its return/errno
|
||||
// convention. It never delegates to a real connection attempt.
|
||||
#[unsafe(no_mangle)]
|
||||
unsafe extern "C" fn connect(
|
||||
socket: libc::c_int,
|
||||
address: *const libc::sockaddr,
|
||||
length: libc::socklen_t,
|
||||
) -> libc::c_int {
|
||||
let path_offset = std::mem::offset_of!(libc::sockaddr_un, sun_path);
|
||||
let expected = b"/run/signal-cli/socket\0";
|
||||
if !address.is_null()
|
||||
&& let Some(path_end) = path_offset.checked_add(expected.len())
|
||||
&& usize::try_from(length).is_ok_and(|length| length >= path_end)
|
||||
{
|
||||
// SAFETY: connect's caller supplies an address valid for length bytes.
|
||||
// The bounds check above covers the entire pathname slice.
|
||||
let path = unsafe {
|
||||
std::slice::from_raw_parts(address.cast::<u8>().add(path_offset), expected.len())
|
||||
};
|
||||
if path == expected
|
||||
&& let Some(stream) = CONNECTION.with_borrow_mut(Option::take)
|
||||
{
|
||||
// SAFETY: Both descriptors are live and owned by this thread.
|
||||
// dup2 replaces the unconnected socket with the test endpoint.
|
||||
return if unsafe { libc::dup2(stream.as_raw_fd(), socket) } < 0 {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
}
|
||||
// SAFETY: __errno_location returns this thread's valid errno pointer.
|
||||
unsafe { *libc::__errno_location() = libc::ECONNREFUSED };
|
||||
-1
|
||||
}
|
||||
|
||||
fn test_connection() -> UnixStream {
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
CONNECTION.with_borrow_mut(|connection| assert!(connection.replace(client).is_none()));
|
||||
server
|
||||
}
|
||||
|
||||
fn signal() -> Signal {
|
||||
Signal {
|
||||
from_phone: "1234567890".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reuses_sender_for_different_recipients_and_ignores_unrelated_responses() {
|
||||
let client = signal();
|
||||
for (to_phone, body) in [
|
||||
("0987654321", "failure\nwith details"),
|
||||
("+15555550102", "another failure"),
|
||||
] {
|
||||
let server = test_connection();
|
||||
let task = thread::spawn(move || {
|
||||
let mut reader = BufReader::new(server);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(
|
||||
request,
|
||||
json!({
|
||||
"jsonrpc": "2.0", "method": "send", "id": "signal-alert",
|
||||
"params": { "account": "1234567890", "recipient": [to_phone], "message": body },
|
||||
})
|
||||
);
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(b"{\"method\":\"receive\"}\n{\"id\":\"other\"}\n{\"id\":\"signal-")
|
||||
.unwrap();
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(b"alert\",\"result\":{}}\n")
|
||||
.unwrap();
|
||||
});
|
||||
client.send(to_phone, body).unwrap();
|
||||
task.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_rpc_errors_invalid_json_and_premature_close() {
|
||||
for (reply, expected) in [
|
||||
(
|
||||
"{\"id\":\"signal-alert\",\"error\":{\"code\":-1}}\n",
|
||||
"Signal RPC returned an error",
|
||||
),
|
||||
("invalid\n", "Invalid Signal RPC response"),
|
||||
("", "socket closed"),
|
||||
("{\"id\":\"signal-alert\"}", "socket closed"),
|
||||
] {
|
||||
let server = test_connection();
|
||||
let task = thread::spawn(move || {
|
||||
let mut reader = BufReader::new(server);
|
||||
reader.read_line(&mut String::new()).unwrap();
|
||||
reader.get_mut().write_all(reply.as_bytes()).unwrap();
|
||||
});
|
||||
let error = signal().send("0987654321", "failure").unwrap_err();
|
||||
assert!(format!("{error:#}").contains(expected), "{error:#}");
|
||||
task.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_connection_failure() {
|
||||
let error = signal().send("0987654321", "failure").unwrap_err();
|
||||
assert_eq!(error.to_string(), "Failed to connect to Signal RPC socket");
|
||||
assert_eq!(
|
||||
error.downcast_ref::<io::Error>().unwrap().kind(),
|
||||
io::ErrorKind::ConnectionRefused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresponsive_server_times_out() {
|
||||
let _server = test_connection();
|
||||
let start = Instant::now();
|
||||
let error = signal().send("0987654321", "failure").unwrap_err();
|
||||
assert_eq!(error.to_string(), "Failed to read Signal RPC response");
|
||||
assert!(matches!(
|
||||
error.downcast_ref::<io::Error>().unwrap().kind(),
|
||||
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
|
||||
));
|
||||
assert!(start.elapsed() >= Duration::from_secs(4));
|
||||
}
|
||||
Reference in new issue
Block a user