diff --git a/Cargo.lock b/Cargo.lock index 93fb039..ea2903a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -333,6 +333,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -447,6 +458,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1055,6 +1075,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1270,6 +1291,7 @@ dependencies = [ "lettre", "log", "log4rs", + "rand 0.10.2", "sequoia-cert-store", "sequoia-directories", "sequoia-keystore", @@ -1614,7 +1636,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2290,6 +2312,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2328,6 +2361,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" version = "1.12.0" @@ -2940,7 +2979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/Cargo.toml b/Cargo.toml index 09748ea..8082def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ indymilter = "0.3.0" lettre = "0.11.22" log = "0.4.30" log4rs = "1.3.0" +rand = "0.10.2" sequoia-cert-store = "0.7.3" sequoia-directories = "0.1.0" sequoia-keystore = "0.7.2" diff --git a/config/config.toml b/config/config.toml index 9f7f7ec..5cab41a 100644 --- a/config/config.toml +++ b/config/config.toml @@ -8,6 +8,10 @@ sequoia_home = "/tmp/sq_home" #keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] keyservers = [ "hkp://example.com" ] +[updater] +wait_min = 1 +wait_max = 2 + [logging.appenders.console] kind = "console" [logging.appenders.console.encoder] diff --git a/config/log4rs.yml b/config/log4rs.yml deleted file mode 100644 index 64797bb..0000000 --- a/config/log4rs.yml +++ /dev/null @@ -1,16 +0,0 @@ -appenders: - stdout_appender: - kind: console - encoder: - pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}" - - file_appender: - kind: file - path: "/tmp/husk.log" - encoder: - pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}" - -root: - level: debug - appenders: - - stdout_appender diff --git a/src/cli/cli_args.rs b/src/cli/cli_args.rs index 08a46d3..24d44a7 100644 --- a/src/cli/cli_args.rs +++ b/src/cli/cli_args.rs @@ -34,6 +34,7 @@ pub enum HuskSubcommands { Introducer(IntroducerCommand), Locals(LocalsCommand), Signkey(SignkeyCommand), + Updater(UpdaterCommand), } // Daemon subcommands @@ -72,6 +73,42 @@ pub struct DaemonStartCommand { background: bool, } +// Updater subcommands + +#[derive(Parser, Debug)] +#[clap( + name = "updater", + about = "Manage the updater process.", + subcommand_required = true, + arg_required_else_help = true, + disable_colored_help = true, + disable_version_flag = true, +)] +pub struct UpdaterCommand { + #[clap(subcommand)] + pub subcommand: UpdaterSubcommand, +} +#[derive(Debug, Subcommand)] +pub enum UpdaterSubcommand { + Start(UpdaterStartCommand), + Stop, + Status +} + +#[derive(Parser, Debug)] +#[clap( + name = "daemon", + about = "Start the updater.", +)] +pub struct UpdaterStartCommand { + #[clap( + long = "background", + help = "Start the updater as a background process.", + default_value_t = false, + )] + background: bool, +} + // Introducer subcommands #[derive(Parser, Debug)] diff --git a/src/commands.rs b/src/commands.rs index acb5cfd..52830da 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,6 +7,7 @@ use crate::cli::cli_args::{CliArgs, HuskSubcommands}; use crate::config::HuskConfigContainer; pub mod daemon; +pub mod updater; pub mod introducer; pub mod locals; pub mod signkey; @@ -48,6 +49,9 @@ pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> { HuskSubcommands::Signkey(subcmd) => { commands::signkey::dispatch(subcmd, config).await?; } + HuskSubcommands::Updater(subcmd) => { + commands::updater::dispatch(subcmd, config).await?; + } } Ok(()) diff --git a/src/commands/updater.rs b/src/commands/updater.rs new file mode 100644 index 0000000..e90eb4b --- /dev/null +++ b/src/commands/updater.rs @@ -0,0 +1,49 @@ +//! Updater first level dispatcher + +use anyhow::Result; + +use crate::cli::cli_args::{UpdaterCommand, UpdaterSubcommand}; +use crate::common::process_descr::ProcessDescr; +use crate::config::HuskConfigContainer; +use crate::config::HuskConfig; +use crate::updater::Updater; + +pub async fn dispatch(cmd: UpdaterCommand, config: HuskConfigContainer) -> Result<()> { + + let c: HuskConfig = config.clone().into(); + + match cmd.subcommand { + UpdaterSubcommand::Start(_sub_cmd) => { + // Write the pid file. + let pid_file_name = c.get_updater_pid_file(); + ProcessDescr::create(pid_file_name.clone())?; + + let result_code = Updater::run(config).await; + + // and remove it when the daemon terminated. + let _ = ProcessDescr::remove(pid_file_name); + return result_code; + }, + UpdaterSubcommand::Stop => { + let process_descr = ProcessDescr::load(c.get_updater_pid_file())?; + if process_descr.send_sig_interrupt().is_ok() { + let _ = ProcessDescr::remove(process_descr.file_location); + } + }, + UpdaterSubcommand::Status => { + if let Ok(process_descr) = ProcessDescr::load(c.get_updater_pid_file()) { + if process_descr.is_running() { + println!("Running, with pid: {}", process_descr.pid); + } else { + println!("Not running, but stale pid file detected: {}", + c.get_updater_pid_file()); + } + } else { + println!("Not running"); + } + } + } + + Ok(()) +} + diff --git a/src/config.rs b/src/config.rs index 3096469..08b3615 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,9 +10,21 @@ use log4rs; use crate::types::errors::HuskError; use crate::types::defaults::CONSOLE_LOGGING; +use crate::types::defaults::UPDATER_PID_FILE_LOCATION; +use crate::types::defaults::UPDATER_STATE_FILE_LOCATION; +use crate::types::defaults::UPDATER_WAIT_MIN; +use crate::types::defaults::UPDATER_WAIT_MAX; use crate::types::defaults::PID_FILE_LOCATION; use crate::types::defaults::SUBJECT_REPLACEMENT; +#[derive(Deserialize, Debug, Clone)] +pub struct UpdaterConfig { + pub wait_min: Option, + pub wait_max: Option, + pub pid_file: Option, + pub state_file: Option, +} + #[derive(Deserialize, Debug, Clone)] pub struct HuskConfig { pub connection: String, @@ -20,6 +32,7 @@ pub struct HuskConfig { pub keyservers: Option>, pub subject_replacement: Option, pub pid_file: Option, + updater: Option, logging: Option, } @@ -79,6 +92,7 @@ impl HuskConfig { SUBJECT_REPLACEMENT.to_string() } } + pub fn get_pid_file(&self) -> String { if let Some(pid_file) = &self.pid_file { pid_file.clone() @@ -86,6 +100,53 @@ impl HuskConfig { PID_FILE_LOCATION.to_string() } } + + pub fn get_updater_pid_file(&self) -> String { + if let Some(updater_config) = &self.updater { + if let Some(pid_file) = &updater_config.pid_file { + pid_file.clone() + } else { + UPDATER_PID_FILE_LOCATION.to_string() + } + } else { + UPDATER_PID_FILE_LOCATION.to_string() + } + } + + pub fn get_updater_state_file(&self) -> String { + if let Some(updater_config) = &self.updater { + if let Some(state_file) = &updater_config.state_file { + state_file.clone() + } else { + UPDATER_STATE_FILE_LOCATION.to_string() + } + } else { + UPDATER_STATE_FILE_LOCATION.to_string() + } + } + + pub fn get_updater_min_wait(&self) -> u32 { + if let Some(updater_config) = &self.updater { + if let Some(wait_min) = &updater_config.wait_min { + *wait_min + } else { + UPDATER_WAIT_MIN + } + } else { + UPDATER_WAIT_MIN + } + } + pub fn get_updater_max_wait(&self) -> u32 { + if let Some(updater_config) = &self.updater { + if let Some(wait_max) = &updater_config.wait_max { + *wait_max + } else { + UPDATER_WAIT_MAX + } + } else { + UPDATER_WAIT_MAX + } + } } pub type HuskConfigContainer = Arc>; @@ -113,6 +174,7 @@ mod tests { subject_replacement: None, logging: None, pid_file: None, + updater: None, }; assert_eq!(config.get_subject_replacement(), defaults::SUBJECT_REPLACEMENT); diff --git a/src/main.rs b/src/main.rs index f7f2985..aac784b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use config::{HuskConfig, HuskConfigContainer}; pub mod types; pub mod mail; pub mod daemon; -use daemon::Daemon; +pub mod updater; pub mod common; pub fn main() -> anyhow::Result<()> { @@ -26,6 +26,7 @@ pub fn main() -> anyhow::Result<()> { // XXX keep this in sync with cli/cli_args.rs for arg in std::env::args() { if arg == "daemon" && daemonize == 0 { daemonize = 1; } + if arg == "updater" && daemonize == 0 { daemonize = 1; } if arg == "start" && daemonize == 1 { daemonize = 2; } if arg == "--background" && daemonize == 2 { daemonize = 3; } } diff --git a/src/types/defaults.rs b/src/types/defaults.rs index 774301d..12e0446 100644 --- a/src/types/defaults.rs +++ b/src/types/defaults.rs @@ -6,7 +6,12 @@ pub const CONFIG_FILE_LOCATION: &str = "/etc/husk/config.toml"; /// and replaced by `SUBJECT_REPLACEMENT` (unless configured otherwise). pub const SUBJECT_REPLACEMENT: &str = "..."; -pub const PID_FILE_LOCATION: &str = "/run/husk/pid"; +pub const PID_FILE_LOCATION: &str = "/var/lib/husk/husk.pid"; +pub const UPDATER_PID_FILE_LOCATION: &str = "/var/lib/husk/updater.pid"; +pub const UPDATER_STATE_FILE_LOCATION: &str = "/var/lib/husk/updater.state"; + +pub const UPDATER_WAIT_MIN: u32 = 5; +pub const UPDATER_WAIT_MAX: u32 = 3600; pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n"; pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n"; diff --git a/src/updater/mod.rs b/src/updater/mod.rs new file mode 100644 index 0000000..db49460 --- /dev/null +++ b/src/updater/mod.rs @@ -0,0 +1,85 @@ +//! Husk certificate updater + +use std::path::Path; +use std::sync::Arc; + +use sequoia_cert_store::LazyCert; +use sequoia_cert_store::StoreUpdate; +use sequoia_cert_store::store::MergePublicCollectStats; +use tokio::time::sleep; +use tokio::time::Duration; + +use crate::config::HuskConfig; +use crate::config::HuskConfigContainer; +use crate::common::crypto; +use crate::types::husk_context::HuskContext; +use state::UpdaterState; + +pub mod state; + +pub struct Updater { } + +impl Updater { + + async fn delay(min: u32, max: u32) { + + let val = rand::random_range(min..max); + log::debug!("updater sleeping for {} secs", val); + sleep(Duration::from_secs(val as u64)).await; + } + + pub async fn run(config_container: HuskConfigContainer) -> anyhow::Result<()> { + + let c: HuskConfig = config_container.into(); + + let mut stay = true; + let state_file = c.get_updater_state_file(); + let state_path = Path::new(&state_file); + while stay { + Self::delay(c.get_updater_min_wait(), c.get_updater_max_wait()).await; + + let mut updater_state = UpdaterState::thaw(state_path) + .await + .map_err(|e| { stay = false; e })?; + let context = &HuskContext::without_signkey(&c)?; + updater_state.refresh(context); + + if let Some(fpr) = updater_state.next() { + log::debug!("try to update {}", fpr.to_hex()); + + let stats = MergePublicCollectStats::new(); + + let certs = crypto::lookup_fingerprint(context, fpr.to_hex().as_str()).await?; + if !certs.is_empty() { + for cert in certs { + let cert = Arc::new(LazyCert::from_cert(cert)); + if context.cert_store.update_by(cert, &stats).is_ok() { + updater_state.mark_processed(&fpr, stats.updated_certs() > 0); + } else { + updater_state.incr_retry_count(&fpr); + } + } + } else { + // no updates found + updater_state.mark_processed(&fpr, false); + } + UpdaterState::freeze(state_path, &updater_state) + .await + .map_err(|e| { stay = false; e })?; + } else { + // finished update round + let (processed, updated) = updater_state.summarize(); + log::info!("Updater finished round: {} certificates processed, {} updated", + processed, + updated); + // remove state from disk + UpdaterState::remove(state_path) + .await + .map_err(|e| { stay = false; e })?; + } + } + + Ok(()) + } + +} diff --git a/src/updater/state.rs b/src/updater/state.rs new file mode 100644 index 0000000..9b9f2e5 --- /dev/null +++ b/src/updater/state.rs @@ -0,0 +1,461 @@ +//! manage the state of certificates while updating + +use std::{fmt, fs}; +use std::collections::BTreeMap; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::Path; + +use chrono::{Local, NaiveDateTime}; +use rand::prelude::*; +use sequoia_openpgp::Fingerprint; +use sequoia_openpgp::types::RevocationStatus; +use sequoia_openpgp::parse::buffered_reader::File; +use sequoia_cert_store::Store; +use thiserror::Error; + +use crate::types::husk_context::HuskContext; + +#[derive(Error, Debug)] +pub enum UpdaterError { + #[error("IO Error {0}")] + IoError(String), + #[error("Start time missing")] + StartTimeMissing, + #[error("Wrong record size {0}")] + WrongRecordSize(usize), + #[error("Data missing")] + DataMissing, + #[error("Broken persisted cert state: {0}")] + BrokenState(String), + #[error("File not found {0}")] + FileNotFound(String), +} + +const MAX_RETRY: u32 = 3; +const DATETIME_PATTERN: &str = "%Y-%m-%d %H:%M:%S"; + +#[derive(Debug, PartialEq)] +pub struct CertState { + fpr: Fingerprint, + processed: bool, + updated: bool, + // XXX: do we really want to retry within the same update round? + retry_count: u32, +} + +impl fmt::Display for CertState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}:{}:{}:{}", + self.fpr, + self.processed, + self.updated, + self.retry_count) + } +} + +impl TryFrom<&str> for CertState { + type Error = UpdaterError; + + fn try_from(line: &str) -> Result { + let line = line.trim(); + let parts: Vec<&str> = line.split(":").collect(); + + if parts.len() != 4 { + return Err(UpdaterError::WrongRecordSize(parts.len())); + } + + let fpr = parts[0] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[0].to_string()))?; + let processed = parts[1] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[1].to_string()))?; + let updated = parts[2] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[2].to_string()))?; + let retry_count = parts[3] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[3].to_string()))?; + Ok(CertState { + fpr, + processed, + updated, + retry_count, + }) + } +} + +impl TryFrom for CertState { + type Error = UpdaterError; + + fn try_from(line: String) -> Result { + line.as_str().try_into() + } +} + +impl CertState { + + pub fn new(fpr: Fingerprint) -> Self { + Self { + fpr: fpr, + processed: false, + updated: false, + retry_count: 0, + } + } + + pub fn need_processing(&self) -> bool { + !(self.processed || (self.retry_count >= MAX_RETRY)) + } + + /// Mark this certificate as processed - this doesn't mean that + /// there was an actual update. + pub fn mark_processed(&mut self) { + self.processed = true; + } + + /// Mark this certificate as updated - this doesn't mark the + /// certificate as processed. + pub fn mark_updated(&mut self) { + self.updated = true; + } + + /// Increment the retry count. + pub fn incr_retry_count(&mut self) { + self.retry_count += 1; + } +} + +#[derive(Debug, PartialEq)] +pub struct UpdaterState { + started: NaiveDateTime, + certs: BTreeMap, +} + +impl TryFrom for UpdaterState { + type Error = UpdaterError; + + fn try_from(data: String) -> Result { + let mut result = BTreeMap::new(); + let mut lines = data.lines(); + let started = lines + .next() + .and_then(|l| + NaiveDateTime::parse_from_str(l, DATETIME_PATTERN).ok()); + if started.is_none() { + return Err(UpdaterError::DataMissing); + } + + for line in lines { + if line.is_empty() { continue; } + let cert_state: CertState = line.try_into()?; + result.insert(cert_state.fpr.clone(), cert_state); + } + Ok(UpdaterState { + started: started.unwrap(), + certs: result + }) + } + +} + +impl TryFrom> for UpdaterState { + type Error = UpdaterError; + + fn try_from(data: Vec) -> Result { + Self::try_from(String::from_utf8_lossy(&data[..]).to_string()) + } +} + +impl fmt::Display for UpdaterState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.started.format(DATETIME_PATTERN))?; + for (_, cert) in &self.certs { + write!(f, "{}", cert)?; + } + + Ok(()) + } +} + +impl UpdaterState { + + pub fn new() -> Self { + Self { + started: Local::now().naive_local(), + certs: BTreeMap::new() + } + } + + /// Get the next certificate from the updater, which still needs + /// processing - this includes retries. + pub fn next(&mut self) -> Option { + let set: Vec<&CertState> = self.certs.values() + .filter(|c| c.need_processing()) + .collect(); + let mut rng = rand::rng(); + set.choose(&mut rng) + .and_then(|c| Some(c.fpr.clone())) + } + + /// Mark the certificate (identified by its fingerprint) as processed. Also + /// mark it as updated if `updated` is true. + pub fn mark_processed(&mut self, fpr: &Fingerprint, updated: bool) { + if let Some(entry) = self.certs.get_mut(fpr) { + entry.mark_processed(); + if updated { + entry.mark_updated(); + } + } + } + + /// Increment the retry count of the certificate (identified by its + /// fingerprint. + pub fn incr_retry_count(&mut self, fpr: &Fingerprint) { + if let Some(entry) = self.certs.get_mut(fpr) { + entry.incr_retry_count(); + } + } + + /// Scan the certificate store for new certificates and add them to the + /// state (as unprocessed). + pub fn refresh(&mut self, context: &HuskContext<'_>) { + + let count = self.certs.keys().count(); + + context.cert_store.certs() + .filter(|c| { + if let Ok(vc) = c.with_policy(&context.policy, None) { + // Ignore revoked certificates. + if let RevocationStatus::Revoked(_) = vc.revocation_status() { + return false; + } + + // Ignore non exportable certificates, this includes internal + // certificates like the trust root or shadow CAs. + vc.cert().exportable() + } else { + // Certificate is invalid under the current policy. + false + } + }) + .map(|c| c.fingerprint()) + .for_each(|fpr| { + if ! self.certs.contains_key(&fpr) { + self.certs.insert(fpr.clone(), CertState::new(fpr)); + } + }); + + log::debug!("refresh from {} to {}", + count, + self.certs.keys().count()); + } + + /// Recreate the state from file. + pub async fn thaw(file: &Path) -> Result { + + if !file.exists() { + log::debug!("file not existing: {:?}", file); + if let Some(parent) = file.parent() { + log::debug!("assert parent directory"); + std::fs::create_dir_all(parent) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + } + return Ok(Self::new()); + } + + let meta = file.metadata() + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + let updater_state = if meta.len() == 0 { + // no previous state + Self::new() + } else { + let mut handle = File::open(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + let mut content = Vec::with_capacity(meta.len() as usize); + handle.read_to_end(&mut content) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + content.try_into()? + }; + + Ok(updater_state) + } + + /// Persist state to file. + pub async fn freeze(file: &Path, updater_state: &UpdaterState) -> Result<(), UpdaterError> { + // freeze comes after thaw, so we assume that the parent directory + // exists. + // XXX: on unix set mode 0o666 + let mut handle = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + + handle.write_all(updater_state.to_string().as_bytes()) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + + Ok(()) + } + + /// Remove the state file. + pub async fn remove(file: &Path) -> Result<(), UpdaterError> { + if file.exists() { + fs::remove_file(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + } + Ok(()) + } + + /// Create some statistics. + pub fn summarize(&self) -> (usize, usize) { + let mut processed = 0; + let mut updated = 0; + for (_, cert) in &self.certs { + if cert.processed { processed += 1; } + if cert.updated { updated += 1; } + } + (processed, updated) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::updater::state::{DATETIME_PATTERN, UpdaterError}; + use chrono::NaiveDateTime; + use sequoia_openpgp::Fingerprint; + + use super::{CertState, UpdaterState}; + + + #[test] + fn serialize_deserialize( ) -> Result<(), UpdaterError> { + + let cert_state = CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }; + + let serialized: String = format!("{}", cert_state); + let deserialized: CertState = serialized.try_into()?; + + assert_eq!(cert_state, deserialized); + + let cert_state = CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }; + + let serialized: String = format!("{}", cert_state); + eprintln!("{}", serialized); + + let deserialized: CertState = serialized.try_into()?; + + assert_eq!(cert_state, deserialized); + + Ok(()) + } + + #[test] + fn serialize_deserialize_store( ) -> Result<(), UpdaterError> { + + let store = UpdaterState { + started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN) + .map_err(|_e| UpdaterError::BrokenState("in test".into()))?, + certs: [ + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 1, + } + ].into_iter() + .map(|c| (c.fpr.clone(), c)) + .collect::>() + }; + + let serialized = store.to_string(); + eprintln!("{}", serialized); + let deserialized: UpdaterState = serialized.try_into()?; + + assert_eq!(store, deserialized); + + Ok(()) + } + + #[test] + fn update_turnaround() -> Result<(), UpdaterError> { + let mut store = UpdaterState { + started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN) + .map_err(|_e| UpdaterError::BrokenState("in test".into()))?, + certs: [ + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + } + ].into_iter() + .map(|c| (c.fpr.clone(), c)) + .collect::>() + }; + + let mut break_counter = 0; + // this loop has to end + while let Some(fpr) = store.next() { + assert!(break_counter < store.certs.len()); + break_counter += 1; + store.mark_processed(&fpr, false); + } + + Ok(()) + } +}