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
Generated
+170
@@ -290,6 +290,28 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -381,6 +403,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -613,6 +641,16 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -684,6 +722,12 @@ version = "0.2.189"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -971,6 +1015,19 @@ version = "2.1.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.1",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
@@ -1061,6 +1118,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1079,6 +1145,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"libc",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.12"
|
version = "0.4.12"
|
||||||
@@ -1091,6 +1166,21 @@ version = "1.15.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "snapshot-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"signal",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -1161,6 +1251,19 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.20"
|
version = "2.0.20"
|
||||||
@@ -1230,6 +1333,45 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1345,6 +1487,7 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
|
"num-traits",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1590,6 +1733,18 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -1679,6 +1834,21 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zfs"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"signal",
|
||||||
|
"tempfile",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+29
@@ -2,3 +2,32 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
members = ["rust/*"]
|
members = ["rust/*"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
clap = "4.5"
|
||||||
|
env_logger = "0.11"
|
||||||
|
log = "0.4"
|
||||||
|
serde = "1.0"
|
||||||
|
serde_json = "1.0"
|
||||||
|
signal = { path = "rust/signal" }
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
all = { level = "deny", priority = -1 }
|
||||||
|
pedantic = { level = "deny", priority = -1 } # UM, ACTUALLY
|
||||||
|
nursery = { level = "deny", priority = -1 } # BETA LINTS
|
||||||
|
# DENY PANICS
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
indexing_slicing = "deny"
|
||||||
|
arithmetic_side_effects = "deny"
|
||||||
|
unreachable = "deny"
|
||||||
|
unimplemented = "deny"
|
||||||
|
unchecked_time_subtraction = "deny"
|
||||||
|
todo = "deny"
|
||||||
|
string_slice = "deny"
|
||||||
|
panic_in_result_fn = "deny"
|
||||||
|
panic = "deny"
|
||||||
|
exit = "deny"
|
||||||
|
as_conversions = "deny"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
allow-unwrap-in-tests = true
|
||||||
|
allow-expect-in-tests = true
|
||||||
|
allow-panic-in-tests = true
|
||||||
|
allow-indexing-slicing-in-tests = true
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
+10
-11
@@ -6,16 +6,15 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
anyhow.workspace = true
|
||||||
chrono = "0.4"
|
chrono.workspace = true
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { workspace = true, features = ["derive", "env"] }
|
||||||
env_logger = "0.11"
|
env_logger.workspace = true
|
||||||
log = "0.4"
|
log.workspace = true
|
||||||
|
num-traits = "0.2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json.workspace = true
|
||||||
|
|
||||||
[lints.clippy]
|
|
||||||
all = "deny"
|
|
||||||
pedantic = "deny"
|
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
{ rustPlatform }:
|
{ lib, rustPlatform }:
|
||||||
rustPlatform.buildRustPackage {
|
rustPlatform.buildRustPackage {
|
||||||
pname = "van-weather";
|
pname = "van-weather";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = lib.fileset.toSource {
|
||||||
|
root = ../..;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
../../Cargo.toml
|
||||||
|
../../Cargo.lock
|
||||||
|
../../clippy.toml
|
||||||
|
../.
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ../../Cargo.lock;
|
||||||
|
cargoBuildFlags = [
|
||||||
|
"-p"
|
||||||
|
"van-weather"
|
||||||
|
];
|
||||||
|
cargoTestFlags = [
|
||||||
|
"-p"
|
||||||
|
"van-weather"
|
||||||
|
];
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
description = "Privacy-masked van weather publisher for Home Assistant";
|
description = "Privacy-masked van weather publisher for Home Assistant";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail};
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
|
use num_traits::ToPrimitive;
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
StatusCode, Url,
|
StatusCode, Url,
|
||||||
blocking::{Client, ClientBuilder},
|
blocking::{Client, ClientBuilder},
|
||||||
@@ -25,7 +26,7 @@ const fn decimal_factor(decimals: u32) -> f64 {
|
|||||||
let mut remaining = decimals;
|
let mut remaining = decimals;
|
||||||
while remaining > 0 {
|
while remaining > 0 {
|
||||||
factor *= 10.0;
|
factor *= 10.0;
|
||||||
remaining -= 1;
|
remaining = remaining.saturating_sub(1);
|
||||||
}
|
}
|
||||||
factor
|
factor
|
||||||
}
|
}
|
||||||
@@ -468,18 +469,25 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
|
fn sensor(entity_id: &str, state: Option<Value>, attributes: Value) -> Option<(String, Value)> {
|
||||||
state.map(|state| {
|
let state = state?;
|
||||||
let mut payload = serde_json::Map::new();
|
let mut payload = serde_json::Map::new();
|
||||||
payload.insert("state".to_owned(), state);
|
payload.insert("state".to_owned(), state);
|
||||||
payload.insert("attributes".to_owned(), attributes);
|
payload.insert("attributes".to_owned(), attributes);
|
||||||
(entity_id.to_owned(), Value::Object(payload))
|
Some((entity_id.to_owned(), Value::Object(payload)))
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
fn percent(value: Option<f64>) -> i64 {
|
fn percent(value: Option<f64>) -> i64 {
|
||||||
// Preserve Python's int(probability * 100) behavior for Home Assistant.
|
// Preserve Python's int(probability * 100) behavior for Home Assistant.
|
||||||
(value.unwrap_or_default() * 100.0) as i64
|
let value = value.unwrap_or_default() * 100.0;
|
||||||
|
value.to_i64().unwrap_or_else(|| {
|
||||||
|
if value.is_nan() {
|
||||||
|
0
|
||||||
|
} else if value.is_sign_negative() {
|
||||||
|
i64::MIN
|
||||||
|
} else {
|
||||||
|
i64::MAX
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -562,4 +570,21 @@ mod tests {
|
|||||||
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
|
assert!((mask_coordinate(37.7749) - 37.8).abs() < f64::EPSILON);
|
||||||
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
|
assert!((mask_coordinate(-122.4194) - (-122.4)).abs() < f64::EPSILON);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentages_truncate_and_preserve_saturation_for_extreme_values() {
|
||||||
|
for (value, expected) in [
|
||||||
|
(None, 0),
|
||||||
|
(Some(0.129), 12),
|
||||||
|
(Some(-0.129), -12),
|
||||||
|
(Some(1.0), 100),
|
||||||
|
(Some(f64::NAN), 0),
|
||||||
|
(Some(f64::MAX), i64::MAX),
|
||||||
|
(Some(f64::MIN), i64::MIN),
|
||||||
|
(Some(f64::INFINITY), i64::MAX),
|
||||||
|
(Some(f64::NEG_INFINITY), i64::MIN),
|
||||||
|
] {
|
||||||
|
assert_eq!(percent(value), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user