diff --git a/Cargo.lock b/Cargo.lock index 04a6d6d..d48bec9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,28 @@ dependencies = [ "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]] name = "find-msvc-tools" version = "0.1.11" @@ -381,6 +403,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -613,6 +641,16 @@ dependencies = [ "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]] name = "ipnet" version = "2.12.1" @@ -684,6 +722,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -971,6 +1015,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "rustls" version = "0.23.43" @@ -1061,6 +1118,15 @@ dependencies = [ "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]] name = "serde_urlencoded" version = "0.7.1" @@ -1079,6 +1145,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "serde_json", +] + [[package]] name = "slab" version = "0.4.12" @@ -1091,6 +1166,21 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "snapshot-manager" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "env_logger", + "log", + "serde", + "signal", + "tempfile", + "toml", +] + [[package]] name = "socket2" version = "0.6.5" @@ -1161,6 +1251,19 @@ dependencies = [ "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]] name = "thiserror" version = "2.0.20" @@ -1230,6 +1333,45 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" @@ -1345,6 +1487,7 @@ dependencies = [ "clap", "env_logger", "log", + "num-traits", "reqwest", "serde", "serde_json", @@ -1590,6 +1733,18 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "writeable" version = "0.6.4" @@ -1679,6 +1834,21 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zfs" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "env_logger", + "log", + "serde", + "signal", + "tempfile", + "toml", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index fad950a..6d399ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,32 @@ resolver = "2" 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" diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -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 diff --git a/rust/signal/Cargo.toml b/rust/signal/Cargo.toml new file mode 100644 index 0000000..5c4b11c --- /dev/null +++ b/rust/signal/Cargo.toml @@ -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 diff --git a/rust/signal/src/lib.rs b/rust/signal/src/lib.rs new file mode 100644 index 0000000..942f50a --- /dev/null +++ b/rust/signal/src/lib.rs @@ -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(()); + } + } +} diff --git a/rust/signal/tests/send.rs b/rust/signal/tests/send.rs new file mode 100644 index 0000000..e6de6f1 --- /dev/null +++ b/rust/signal/tests/send.rs @@ -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> = 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::().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::().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::().unwrap().kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + )); + assert!(start.elapsed() >= Duration::from_secs(4)); +} diff --git a/rust/van_weather/Cargo.toml b/rust/van_weather/Cargo.toml index b42328a..0f06717 100644 --- a/rust/van_weather/Cargo.toml +++ b/rust/van_weather/Cargo.toml @@ -6,16 +6,15 @@ description = "Fetch privacy-masked weather for a van and publish it to Home Ass license = "MIT" [dependencies] -anyhow = "1.0" -chrono = "0.4" -clap = { version = "4.5", features = ["derive", "env"] } -env_logger = "0.11" -log = "0.4" +anyhow.workspace = true +chrono.workspace = true +clap = { workspace = true, features = ["derive", "env"] } +env_logger.workspace = true +log.workspace = true +num-traits = "0.2" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -[lints.clippy] -all = "deny" -pedantic = "deny" +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +[lints] +workspace = true diff --git a/rust/van_weather/package.nix b/rust/van_weather/package.nix index de30db6..6a9fa09 100644 --- a/rust/van_weather/package.nix +++ b/rust/van_weather/package.nix @@ -1,10 +1,26 @@ -{ rustPlatform }: +{ lib, rustPlatform }: rustPlatform.buildRustPackage { pname = "van-weather"; 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 = { description = "Privacy-masked van weather publisher for Home Assistant"; diff --git a/rust/van_weather/src/main.rs b/rust/van_weather/src/main.rs index fbc3993..0c83068 100644 --- a/rust/van_weather/src/main.rs +++ b/rust/van_weather/src/main.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; use clap::Parser; use log::{error, info}; +use num_traits::ToPrimitive; use reqwest::{ StatusCode, Url, blocking::{Client, ClientBuilder}, @@ -25,7 +26,7 @@ const fn decimal_factor(decimals: u32) -> f64 { let mut remaining = decimals; while remaining > 0 { factor *= 10.0; - remaining -= 1; + remaining = remaining.saturating_sub(1); } factor } @@ -468,18 +469,25 @@ fn weather_updates(weather: &Weather) -> Vec<(String, Value)> { } fn sensor(entity_id: &str, state: Option, attributes: Value) -> Option<(String, Value)> { - state.map(|state| { - let mut payload = serde_json::Map::new(); - payload.insert("state".to_owned(), state); - payload.insert("attributes".to_owned(), attributes); - (entity_id.to_owned(), Value::Object(payload)) - }) + let state = state?; + let mut payload = serde_json::Map::new(); + payload.insert("state".to_owned(), state); + payload.insert("attributes".to_owned(), attributes); + Some((entity_id.to_owned(), Value::Object(payload))) } -#[allow(clippy::cast_possible_truncation)] fn percent(value: Option) -> i64 { // 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)] @@ -562,4 +570,21 @@ mod tests { assert!((mask_coordinate(37.7749) - 37.8).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); + } + } }