aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock30
-rw-r--r--src/config.rs75
-rw-r--r--src/dyndns_service.rs6
-rw-r--r--src/dyndns_service/gandi.rs4
-rw-r--r--src/error.rs55
-rw-r--r--src/ip_service.rs132
-rw-r--r--src/main.rs3
-rw-r--r--src/test_macros.rs73
-rw-r--r--test/config.bork1
9 files changed, 224 insertions, 155 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 526f328..d8cafaf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -147,9 +147,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
-version = "2.9.0"
+version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
+checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "bumpalo"
@@ -165,9 +165,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
-version = "1.2.22"
+version = "1.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1"
+checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766"
dependencies = [
"shlex",
]
@@ -367,9 +367,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
-version = "0.3.11"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e"
+checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.59.0",
@@ -1562,9 +1562,9 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.19.1"
+version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom 0.3.3",
@@ -1874,15 +1874,15 @@ dependencies = [
[[package]]
name = "windows-core"
-version = "0.61.0"
+version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
+checksum = "46ec44dc15085cea82cf9c78f85a9114c463a369786585ad2882d1ff0b0acf40"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
- "windows-strings 0.4.0",
+ "windows-strings 0.4.1",
]
[[package]]
@@ -1926,9 +1926,9 @@ dependencies = [
[[package]]
name = "windows-result"
-version = "0.3.2"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
+checksum = "4b895b5356fc36103d0f64dd1e94dfa7ac5633f1c9dd6e80fe9ec4adef69e09d"
dependencies = [
"windows-link",
]
@@ -1944,9 +1944,9 @@ dependencies = [
[[package]]
name = "windows-strings"
-version = "0.4.0"
+version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
+checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a"
dependencies = [
"windows-link",
]
diff --git a/src/config.rs b/src/config.rs
index 88fb280..4c8bef1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,37 +1,33 @@
-use std::{fs, io};
-use std::path::Path;
use crate::dyndns_service::DynDnsProvider;
+use crate::error::{AppError, AppResult};
+use crate::ip_service::IpServiceProvider;
use fqdn::FQDN;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
+use std::path::Path;
+use std::{fs, io};
use strum::Display;
-use crate::error::{AppError, AppResult};
-use crate::ip_service::IpServiceProvider;
#[derive(Debug, Deserialize, Serialize)]
#[serde(transparent)]
pub struct Config {
- pub networks: Vec<WanConfig>
+ pub networks: Vec<WanConfig>,
}
impl Config {
-
pub fn load<P: AsRef<Path>>(path: P) -> AppResult<Config> {
let path = path.as_ref();
- let content = fs::read_to_string(path)
- .map_err(|e| match e.kind() {
- io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()),
- _ => AppError::IoError(e),
- })?;
-
- serde_json::from_str(&content)
- .map_err(|e| AppError::ConfigParseError {
- source: e,
- path: path.to_path_buf(),
- })
+ let content = fs::read_to_string(path).map_err(|e| match e.kind() {
+ io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()),
+ _ => AppError::IoError(e),
+ })?;
+
+ serde_json::from_str(&content).map_err(|e| AppError::ConfigParseError {
+ source: e,
+ path: path.to_path_buf(),
+ })
}
-
}
#[derive(Debug, Deserialize, Serialize)]
@@ -41,14 +37,13 @@ pub struct WanConfig {
pub dns_record: DnsRecord,
pub providers: Vec<DynDnsProvider>,
#[serde(default)]
- pub ip_service: IpServiceProvider
+ pub ip_service: IpServiceProvider,
}
-#[derive(Debug, Display, Deserialize, Serialize)]
-#[derive(PartialEq)]
+#[derive(Debug, Display, Deserialize, Serialize, PartialEq)]
pub enum DnsRecordType {
A,
- AAAA
+ AAAA,
}
#[serde_as]
@@ -71,14 +66,15 @@ fn default_ttl() -> u32 {
#[cfg(test)]
mod tests {
- use std::error::Error;
- use std::fs::{read_dir, File};
- use std::io::BufReader;
use super::*;
- use serde_json::json;
- use std::str::FromStr;
use crate::dyndns_service::DynDnsProvider::GANDI;
use crate::dyndns_service::gandi::Gandi;
+ use crate::{assert_config_parse_error, assert_error, assert_file_not_found, assert_io_error};
+ use serde_json::json;
+ use std::error::Error;
+ use std::fs::{File, read_dir};
+ use std::io::BufReader;
+ use std::str::FromStr;
#[test]
fn check_minimal_config() {
@@ -120,7 +116,7 @@ mod tests {
#[test]
fn check_file_configs() -> Result<(), Box<dyn Error>> {
- let path = std::path::Path::new("test");
+ let path = Path::new("test");
for entry in read_dir(path)? {
let entry = entry?;
let test_file_path = entry.path();
@@ -137,4 +133,27 @@ mod tests {
Ok(())
}
+ #[test]
+ fn check_missing_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("test/unknown.yaml");
+
+ assert_file_not_found!(Config::load(path), path);
+ Ok(())
+ }
+
+ #[test]
+ fn check_broken_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("test/config.bork");
+
+ assert_config_parse_error!(Config::load(path), path);
+ Ok(())
+ }
+
+ #[test]
+ fn check_inaccessible_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("/root/secure-config.json");
+
+ assert_io_error!(Config::load(path));
+ Ok(())
+ }
}
diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs
index d1449f5..18cec3e 100644
--- a/src/dyndns_service.rs
+++ b/src/dyndns_service.rs
@@ -1,14 +1,14 @@
pub mod gandi;
+use crate::dyndns_service::gandi::Gandi;
use reqwest::ClientBuilder;
-use std::error::Error;
use serde::{Deserialize, Serialize};
-use crate::dyndns_service::gandi::Gandi;
+use std::error::Error;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type")]
pub enum DynDnsProvider {
- GANDI(Gandi)
+ GANDI(Gandi),
}
pub struct DynDnsService {}
diff --git a/src/dyndns_service/gandi.rs b/src/dyndns_service/gandi.rs
index 03ecf6f..8851b2c 100644
--- a/src/dyndns_service/gandi.rs
+++ b/src/dyndns_service/gandi.rs
@@ -4,12 +4,12 @@
// --header 'content-type: application/json' \
// --data "{ \"rrset_ttl\": 300, \"rrset_values\": [\"1.2.3.4\"] }"
+use crate::config::DnsRecordType;
use crate::dyndns_service::DynDnsServiceConfiguration;
use http::Method;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
-use crate::config::DnsRecordType;
// See https://api.gandi.net/docs/livedns/ for more info
#[serde_as]
@@ -53,8 +53,8 @@ impl DynDnsServiceConfiguration for Gandi {
#[cfg(test)]
mod tests {
- use serde_json::json;
use super::*;
+ use serde_json::json;
#[test]
fn check_defaults() {
diff --git a/src/error.rs b/src/error.rs
index d7bbfaf..3e05d2a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,8 +1,8 @@
+use reqwest::{Error as ReqwestError, Url};
+use serde_json::Error as JsonError;
use std::fmt;
use std::io;
use std::path::PathBuf;
-use reqwest::Error as ReqwestError;
-use serde_json::Error as JsonError;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
@@ -10,38 +10,8 @@ pub enum AppError {
FileNotFound(PathBuf),
IoError(io::Error),
ConfigParseError { source: JsonError, path: PathBuf },
- RequestFailed { url: String, source: ReqwestError },
- InvalidResponse { url: String, reason: String },
- Timeout { url: String },
-}
-
-impl AppError {
- pub fn with_path(self, path: impl Into<PathBuf>) -> Self {
- match self {
- Self::ConfigParseError { source, .. } => Self::ConfigParseError {
- source,
- path: path.into(),
- },
- err => err,
- }
- }
-
- pub fn with_url(self, url: impl Into<String>) -> Self {
- match self {
- Self::RequestFailed { source, .. } => Self::RequestFailed {
- source,
- url: url.into(),
- },
- Self::InvalidResponse { reason, .. } => Self::InvalidResponse {
- reason,
- url: url.into(),
- },
- Self::Timeout { .. } => Self::Timeout {
- url: url.into(),
- },
- err => err,
- }
- }
+ RequestFailed { url: Url, source: ReqwestError },
+ InvalidResponse { url: Url, reason: String },
}
impl fmt::Display for AppError {
@@ -49,12 +19,13 @@ impl fmt::Display for AppError {
match self {
Self::FileNotFound(path) => write!(f, "File not found: {}", path.display()),
Self::IoError(err) => write!(f, "I/O error: {}", err),
- Self::ConfigParseError { path, .. } => write!(f, "Failed to parse config at {}", path.display()),
+ Self::ConfigParseError { path, .. } => {
+ write!(f, "Failed to parse config at {}", path.display())
+ }
Self::RequestFailed { url, .. } => write!(f, "Request to {} failed", url),
Self::InvalidResponse { url, reason } => {
write!(f, "Invalid response from {}: {}", url, reason)
}
- Self::Timeout { url } => write!(f, "Request to {} timed out", url),
}
}
}
@@ -79,10 +50,12 @@ impl From<io::Error> for AppError {
impl From<ReqwestError> for AppError {
fn from(err: ReqwestError) -> Self {
- Self::RequestFailed {
- url: err.url().map_or_else(|| "unknown".to_string(), |u| u.to_string()),
- source: err,
- }
+ let url = match err.url() {
+ Some(url) => url.clone(),
+ None => Url::parse("http://unknown.url").unwrap(),
+ };
+
+ Self::RequestFailed { url, source: err }
}
}
@@ -90,7 +63,7 @@ impl From<JsonError> for AppError {
fn from(err: JsonError) -> Self {
Self::ConfigParseError {
source: err,
- path: PathBuf::from("unknown"), // Default path
+ path: PathBuf::from("unknown"), // Default path
}
}
}
diff --git a/src/ip_service.rs b/src/ip_service.rs
index 1d7f253..fcf3a80 100644
--- a/src/ip_service.rs
+++ b/src/ip_service.rs
@@ -3,9 +3,8 @@ use crate::ip_service::IpServiceProvider::IDENTME;
use http::StatusCode;
use reqwest::{Client, Url};
use serde::{Deserialize, Serialize};
-use serde_with::serde_as;
use serde_with::DisplayFromStr;
-use std::error::Error;
+use serde_with::serde_as;
use std::net::IpAddr;
use std::str::FromStr;
@@ -54,7 +53,7 @@ pub struct IdentMe {
impl IdentMe {
fn convert_string_to_ip_address(input: String, url: &Url) -> AppResult<IpAddr> {
IpAddr::from_str(&input).map_err(|e| AppError::InvalidResponse {
- url: url.to_string(),
+ url: url.clone(),
reason: format!(
"Failed to parse service response [{}] into valid ip address: {}",
input, e
@@ -64,21 +63,21 @@ impl IdentMe {
fn request_failed(&self, e: reqwest::Error) -> AppError {
AppError::RequestFailed {
- url: self.url.to_string(),
+ url: self.url.clone(),
source: e,
}
}
fn invalid_status_code(&self, status: StatusCode) -> AppError {
AppError::InvalidResponse {
- url: self.url.to_string(),
+ url: self.url.clone(),
reason: format!("Status code: {}", status),
}
}
fn missing_text_response(&self, e: reqwest::Error) -> AppError {
AppError::InvalidResponse {
- url: self.url.to_string(),
+ url: self.url.clone(),
reason: format!("Unable to get text from service response: {}", e),
}
}
@@ -121,11 +120,23 @@ impl IpServiceConfiguration for IdentMe {
#[cfg(test)]
mod tests {
use super::*;
- use crate::error::AppError::{InvalidResponse, RequestFailed};
+ use crate::{assert_error, assert_invalid_response, assert_request_failed};
use reqwest::ClientBuilder;
+ use std::error::Error;
+ use std::time::Duration;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
+ trait HasUrl {
+ fn url(&self) -> Url;
+ }
+
+ impl HasUrl for MockServer {
+ fn url(&self) -> Url {
+ Url::from_str(self.uri().as_str()).unwrap()
+ }
+ }
+
async fn setup_ipv4_service(response: impl Respond + 'static) -> MockServer {
let service = MockServer::start().await;
@@ -147,6 +158,10 @@ mod tests {
ResponseTemplate::new(204)
}
+ fn delayed_response(delay: u64) -> impl Respond + 'static {
+ ResponseTemplate::new(204).set_delay(Duration::from_millis(delay))
+ }
+
fn server_error() -> impl Respond + 'static {
ResponseTemplate::new(500)
}
@@ -155,101 +170,89 @@ mod tests {
async fn successful_ipv4_address_resolution() -> Result<(), Box<dyn Error>> {
let expected = IpAddr::from_str("17.5.7.8")?;
let input = plaintext_response(expected.to_string());
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert_eq!(actual.unwrap(), expected);
+ assert_eq!(actual?, expected);
Ok(())
}
#[tokio::test]
- async fn failed_address_resolution_no_server() -> Result<(), Box<dyn Error>> {
+ async fn failed_no_reachable_server() -> Result<(), Box<dyn Error>> {
let client = ClientBuilder::new().build()?;
- let server_url = "http://localhost:8765/path";
- let service = IdentMe {
- url: server_url.parse()?,
- };
+ let url = Url::from_str("http://localhost:8765/path")?;
+ let service = IdentMe { url: url.clone() };
let actual = IDENTME(service).resolve(&client).await;
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- RequestFailed { url, .. } => {
- assert_eq!(url, server_url.to_string());
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_request_failed!(actual, url);
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_status_code() -> Result<(), Box<dyn Error>> {
+ async fn failed_status_code() -> Result<(), Box<dyn Error>> {
let input = server_error();
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, reason } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- assert_eq!(reason, "Status code: 500 Internal Server Error");
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Status code: 500 Internal Server Error"
+ );
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_empty_response() -> Result<(), Box<dyn Error>> {
+ async fn failed_empty_response() -> Result<(), Box<dyn Error>> {
let input = empty_response();
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, .. } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Failed to parse service response [] into valid ip address"
+ );
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_not_an_ip_address() -> Result<(), Box<dyn Error>> {
+ async fn failed_not_an_ip_address() -> Result<(), Box<dyn Error>> {
let input = plaintext_response("not-an-ip-address".to_string());
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, .. } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Failed to parse service response [not-an-ip-address] into valid ip address"
+ );
Ok(())
}
- async fn run(payload: impl Respond + 'static) -> Result<(MockServer, AppResult<IpAddr>), Box<dyn Error>> {
+ #[tokio::test]
+ async fn failed_timeout() -> Result<(), Box<dyn Error>> {
+ let input = delayed_response(200);
+ let (ip_service, actual) = run_identme(input).await?;
+
+ assert_eq!(1, ip_service.received_requests().await.unwrap().len());
+ assert_request_failed!(actual, ip_service.url());
+
+ Ok(())
+ }
+
+ async fn run_identme(
+ payload: impl Respond + 'static,
+ ) -> Result<(MockServer, AppResult<IpAddr>), Box<dyn Error>> {
let ip_service = setup_ipv4_service(payload).await;
- let client = ClientBuilder::new().build()?;
+ let client = ClientBuilder::new()
+ .timeout(Duration::from_millis(100))
+ .build()?;
let service = IdentMe {
url: ip_service.uri().parse()?,
};
@@ -257,7 +260,4 @@ mod tests {
let actual = IDENTME(service).resolve(&client).await;
Ok((ip_service, actual))
}
-
-
-
}
diff --git a/src/main.rs b/src/main.rs
index 06bea8e..57f4c7d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,6 +9,9 @@ mod dyndns_service;
mod error;
mod ip_service;
+#[cfg(test)]
+mod test_macros;
+
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
diff --git a/src/test_macros.rs b/src/test_macros.rs
new file mode 100644
index 0000000..9f3ff2b
--- /dev/null
+++ b/src/test_macros.rs
@@ -0,0 +1,73 @@
+#[macro_export]
+macro_rules! assert_error {
+ ($result:expr, $pattern:pat => $body:block) => {
+ match $result {
+ Ok(_) => panic!("Expected an error, but got Ok result"),
+ Err(e) => match e {
+ $pattern => $body,
+ other => panic!("Expected {}, but got {:?}", stringify!($pattern), other),
+ },
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! assert_file_not_found {
+ ($result:expr, $expected_path:expr) => {
+ assert_error!($result, AppError::FileNotFound(path) => {
+ assert_eq!(path, $expected_path,
+ "Expected file not found for path {:?}, but got path {:?}",
+ $expected_path, path);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_io_error {
+ ($result:expr) => {
+ assert_error!($result, AppError::IoError(_) => {});
+ };
+ ($result:expr, $kind:pat) => {
+ assert_error!($result, AppError::IoError(err) => {
+ assert!(matches!(err.kind(), $kind),
+ "Expected IO error of kind {}, but got {:?}",
+ stringify!($kind), err.kind());
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_config_parse_error {
+ ($result:expr, $expected_path:expr) => {
+ assert_error!($result, AppError::ConfigParseError { path, source: _ } => {
+ assert_eq!(path, $expected_path,
+ "Expected config parse error for path {:?}, but got path {:?}",
+ $expected_path, path);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_request_failed {
+ ($result:expr, $expected_url:expr) => {
+ assert_error!($result, AppError::RequestFailed { url, source: _ } => {
+ assert_eq!(url, $expected_url,
+ "Expected request failed for URL {}, but got URL {}",
+ $expected_url, url);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_invalid_response {
+ ($result:expr, $expected_url:expr, $reason_prefix:expr) => {
+ assert_error!($result, AppError::InvalidResponse{ url, reason } => {
+ assert_eq!(url, $expected_url,
+ "Expected invalid response for URL {}, but got URL {}",
+ $expected_url, url);
+ assert!(reason.starts_with($reason_prefix),
+ "Expected reason to start with '{}', but got '{}'",
+ $reason_prefix, reason);
+ });
+ };
+}
diff --git a/test/config.bork b/test/config.bork
new file mode 100644
index 0000000..f533f7e
--- /dev/null
+++ b/test/config.bork
@@ -0,0 +1 @@
+Bork! Bork! Bork!