- The updater looks for updates to certificates from the certificate
  store.
- The updates are spaced by random time intervals so that the
  reconstruction of the content of the cert store is made more
  difficult.
- The updater can be send into the background - start, stop and status
  are implemented.
- The state of the updater is persisted to disk so that the update
  procedure survives a restart.
This commit is contained in:
Malte Meiboom
2026-08-07 11:11:55 +02:00
parent 118b930c1d
commit 1ed3e1e988
12 changed files with 753 additions and 21 deletions
Generated
+42 -3
View File
@@ -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",
]
+1
View File
@@ -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"
+4
View File
@@ -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]
-16
View File
@@ -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
+37
View File
@@ -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)]
+4
View File
@@ -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(())
+49
View File
@@ -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(())
}
+62
View File
@@ -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<u32>,
pub wait_max: Option<u32>,
pub pid_file: Option<String>,
pub state_file: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct HuskConfig {
pub connection: String,
@@ -20,6 +32,7 @@ pub struct HuskConfig {
pub keyservers: Option<Vec<String>>,
pub subject_replacement: Option<String>,
pub pid_file: Option<String>,
updater: Option<UpdaterConfig>,
logging: Option<toml::Value>,
}
@@ -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<Mutex<HuskConfig>>;
@@ -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);
+2 -1
View File
@@ -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; }
}
+6 -1
View File
@@ -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";
+85
View File
@@ -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(())
}
}
+461
View File
@@ -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<Self, UpdaterError> {
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::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState(parts[0].to_string()))?;
let processed = parts[1]
.parse::<bool>()
.map_err(|_e| UpdaterError::BrokenState(parts[1].to_string()))?;
let updated = parts[2]
.parse::<bool>()
.map_err(|_e| UpdaterError::BrokenState(parts[2].to_string()))?;
let retry_count = parts[3]
.parse::<u32>()
.map_err(|_e| UpdaterError::BrokenState(parts[3].to_string()))?;
Ok(CertState {
fpr,
processed,
updated,
retry_count,
})
}
}
impl TryFrom<String> for CertState {
type Error = UpdaterError;
fn try_from(line: String) -> Result<Self, UpdaterError> {
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<Fingerprint, CertState>,
}
impl TryFrom<String> for UpdaterState {
type Error = UpdaterError;
fn try_from(data: String) -> Result<Self, UpdaterError> {
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<Vec<u8>> for UpdaterState {
type Error = UpdaterError;
fn try_from(data: Vec<u8>) -> Result<Self, UpdaterError> {
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<Fingerprint> {
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<Self, UpdaterError> {
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::<Fingerprint>()
.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::<Fingerprint>()
.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::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 3,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: true,
retry_count: 3,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A"
.parse::<Fingerprint>()
.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::<BTreeMap<Fingerprint, CertState>>()
};
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::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69"
.parse::<Fingerprint>()
.map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?,
processed: false,
updated: false,
retry_count: 0,
},
CertState {
fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A"
.parse::<Fingerprint>()
.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::<BTreeMap<Fingerprint, CertState>>()
};
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(())
}
}