From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 08DAA1FF13F for ; Thu, 09 Apr 2026 11:51:17 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 990811D42C; Thu, 9 Apr 2026 11:51:54 +0200 (CEST) Content-Type: text/plain; charset=UTF-8 Date: Thu, 09 Apr 2026 11:51:43 +0200 Message-Id: From: "Lukas Wagner" To: "Arthur Bied-Charreton" , Subject: Re: [PATCH proxmox v2 02/16] notify: smtp: Introduce state management Content-Transfer-Encoding: quoted-printable Mime-Version: 1.0 X-Mailer: aerc 0.21.0-0-g5549850facc2-dirty References: <20260325131444.366808-1-a.bied-charreton@proxmox.com> <20260325131444.366808-3-a.bied-charreton@proxmox.com> In-Reply-To: <20260325131444.366808-3-a.bied-charreton@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1775728235047 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.055 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: GQFGODP3WPT46AY3INO4NTVA5LBJRHGH X-Message-ID-Hash: GQFGODP3WPT46AY3INO4NTVA5LBJRHGH X-MailFrom: l.wagner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Looking good! With the trivial suggestions implemented: Reviewed-by: Lukas Wagner On Wed Mar 25, 2026 at 2:14 PM CET, Arthur Bied-Charreton wrote: > Export a new State struct in the xoauth2 module with associated > functionality for loading, updating, and persisting the OAuth2 state > for SMTP endpoints. > > The API for loading and saving the state is exposed through the > Context trait, in order to make migration as easy as possible in > a future where we might want to move towards KV storage instead > of files for secret management. It is made specific to oauth state, > because this implementation assumes invariants that hold for oauth2 > refresh tokens (documented in the smtp::xoauth2 module's doc comments), > but are likely to be incorrect for other kinds of state that may be added > in the future. > > The State struct is made public, to support the long-term goal for > the Context trait to be implemented by the products themselves. > > The nix crate is added for the sys::stat::Mode struct, and > proxmox-sys is now pulled in unconditionally since it is now used in the > Context implementations. > > Signed-off-by: Arthur Bied-Charreton > --- > proxmox-notify/Cargo.toml | 12 +- > proxmox-notify/debian/control | 41 +++---- > proxmox-notify/src/context/mod.rs | 14 +++ > proxmox-notify/src/context/pbs.rs | 14 +++ > proxmox-notify/src/context/pve.rs | 17 ++- > proxmox-notify/src/context/test.rs | 14 +++ > proxmox-notify/src/endpoints/smtp.rs | 2 + > proxmox-notify/src/endpoints/smtp/xoauth2.rs | 115 +++++++++++++++++++ > proxmox-notify/src/lib.rs | 12 ++ > 9 files changed, 208 insertions(+), 33 deletions(-) > > diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml > index 421bb6c3..dfd4b8a4 100644 > --- a/proxmox-notify/Cargo.toml > +++ b/proxmox-notify/Cargo.toml > @@ -36,16 +36,18 @@ proxmox-schema =3D { workspace =3D true, features =3D= ["api-macro", "api-types"] } > proxmox-section-config =3D { workspace =3D true } > proxmox-serde.workspace =3D true > proxmox-sendmail =3D { workspace =3D true, optional =3D true } > -proxmox-sys =3D { workspace =3D true, optional =3D true } > +proxmox-sys =3D { workspace =3D true } > proxmox-time.workspace =3D true > proxmox-uuid =3D { workspace =3D true, features =3D ["serde"] } > +nix =3D { workspace =3D true } > + > =20 > [features] > default =3D ["sendmail", "gotify", "smtp", "webhook"] > -mail-forwarder =3D ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendm= ail/mail-forwarder"] > -sendmail =3D ["dep:proxmox-sys", "dep:proxmox-sendmail"] > +mail-forwarder =3D ["dep:mail-parser", "proxmox-sendmail/mail-forwarder"= ] > +sendmail =3D ["dep:proxmox-sendmail"] > gotify =3D ["dep:proxmox-http", "dep:http"] > -pve-context =3D ["dep:proxmox-sys"] > -pbs-context =3D ["dep:proxmox-sys"] > +pve-context =3D [] > +pbs-context =3D [] > smtp =3D ["dep:lettre", "dep:oauth2", "dep:ureq", "dep:http"] > webhook =3D ["dep:http", "dep:percent-encoding", "dep:proxmox-base64", "= dep:proxmox-http"] > diff --git a/proxmox-notify/debian/control b/proxmox-notify/debian/contro= l > index 98e5475c..ca6e7567 100644 > --- a/proxmox-notify/debian/control > +++ b/proxmox-notify/debian/control > @@ -11,6 +11,7 @@ Build-Depends-Arch: cargo:native , > librust-handlebars-5+default-dev , > librust-http-1+default-dev , > librust-lettre-0.11+default-dev (>=3D 0.11.1-~~) , > + librust-nix-0.29+default-dev , > librust-oauth2-5-dev , > librust-openssl-0.10+default-dev , > librust-percent-encoding-2+default-dev (>=3D 2.1-~~) , > @@ -52,6 +53,7 @@ Depends: > librust-anyhow-1+default-dev, > librust-const-format-0.2+default-dev, > librust-handlebars-5+default-dev, > + librust-nix-0.29+default-dev, > librust-openssl-0.10+default-dev, > librust-proxmox-http-error-1+default-dev, > librust-proxmox-human-byte-1+default-dev, > @@ -61,6 +63,7 @@ Depends: > librust-proxmox-section-config-3+default-dev (>=3D 3.1.0-~~), > librust-proxmox-serde-1+default-dev, > librust-proxmox-serde-1+serde-json-dev, > + librust-proxmox-sys-1+default-dev (>=3D 1.0.1-~~), > librust-proxmox-time-2+default-dev (>=3D 2.1.0-~~), > librust-proxmox-uuid-1+default-dev (>=3D 1.1.0-~~), > librust-proxmox-uuid-1+serde-dev (>=3D 1.1.0-~~), > @@ -74,14 +77,21 @@ Recommends: > Suggests: > librust-proxmox-notify+gotify-dev (=3D ${binary:Version}), > librust-proxmox-notify+mail-forwarder-dev (=3D ${binary:Version}), > - librust-proxmox-notify+pbs-context-dev (=3D ${binary:Version}), > librust-proxmox-notify+sendmail-dev (=3D ${binary:Version}), > librust-proxmox-notify+smtp-dev (=3D ${binary:Version}), > librust-proxmox-notify+webhook-dev (=3D ${binary:Version}) > Provides: > + librust-proxmox-notify+pbs-context-dev (=3D ${binary:Version}), > + librust-proxmox-notify+pve-context-dev (=3D ${binary:Version}), > librust-proxmox-notify-1-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1+pbs-context-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1+pve-context-dev (=3D ${binary:Version}), > librust-proxmox-notify-1.0-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1.0.3-dev (=3D ${binary:Version}) > + librust-proxmox-notify-1.0+pbs-context-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1.0+pve-context-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1.0.3-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1.0.3+pbs-context-dev (=3D ${binary:Version}), > + librust-proxmox-notify-1.0.3+pve-context-dev (=3D ${binary:Version}) > Description: Notification base and plugins - Rust source code > Source code for Debianized Rust crate "proxmox-notify" > =20 > @@ -127,8 +137,7 @@ Depends: > ${misc:Depends}, > librust-proxmox-notify-dev (=3D ${binary:Version}), > librust-mail-parser-0.11+default-dev, > - librust-proxmox-sendmail-1+mail-forwarder-dev (>=3D 1.0.2-~~), > - librust-proxmox-sys-1+default-dev (>=3D 1.0.1-~~) > + librust-proxmox-sendmail-1+mail-forwarder-dev (>=3D 1.0.2-~~) > Provides: > librust-proxmox-notify-1+mail-forwarder-dev (=3D ${binary:Version}), > librust-proxmox-notify-1.0+mail-forwarder-dev (=3D ${binary:Version}), > @@ -137,35 +146,13 @@ Description: Notification base and plugins - featur= e "mail-forwarder" > This metapackage enables feature "mail-forwarder" for the Rust proxmox-= notify > crate, by pulling in any additional dependencies needed by that feature= . > =20 > -Package: librust-proxmox-notify+pbs-context-dev > -Architecture: any > -Multi-Arch: same > -Depends: > - ${misc:Depends}, > - librust-proxmox-notify-dev (=3D ${binary:Version}), > - librust-proxmox-sys-1+default-dev (>=3D 1.0.1-~~) > -Provides: > - librust-proxmox-notify+pve-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1+pbs-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1+pve-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1.0+pbs-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1.0+pve-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1.0.3+pbs-context-dev (=3D ${binary:Version}), > - librust-proxmox-notify-1.0.3+pve-context-dev (=3D ${binary:Version}) > -Description: Notification base and plugins - feature "pbs-context" and 1= more > - This metapackage enables feature "pbs-context" for the Rust proxmox-not= ify > - crate, by pulling in any additional dependencies needed by that feature= . > - . > - Additionally, this package also provides the "pve-context" feature. > - > Package: librust-proxmox-notify+sendmail-dev > Architecture: any > Multi-Arch: same > Depends: > ${misc:Depends}, > librust-proxmox-notify-dev (=3D ${binary:Version}), > - librust-proxmox-sendmail-1+default-dev (>=3D 1.0.2-~~), > - librust-proxmox-sys-1+default-dev (>=3D 1.0.1-~~) > + librust-proxmox-sendmail-1+default-dev (>=3D 1.0.2-~~) > Provides: > librust-proxmox-notify-1+sendmail-dev (=3D ${binary:Version}), > librust-proxmox-notify-1.0+sendmail-dev (=3D ${binary:Version}), > diff --git a/proxmox-notify/src/context/mod.rs b/proxmox-notify/src/conte= xt/mod.rs > index 8b6e2c43..783ac6da 100644 > --- a/proxmox-notify/src/context/mod.rs > +++ b/proxmox-notify/src/context/mod.rs > @@ -1,6 +1,7 @@ > use std::fmt::Debug; > use std::sync::Mutex; > =20 > +use crate::endpoints::smtp::State; I think this import must be feature gated, otherwise a=20 cargo build --no-default-features does not compile > use crate::renderer::TemplateSource; > use crate::Error; > =20 > @@ -32,6 +33,19 @@ pub trait Context: Send + Sync + Debug { > namespace: Option<&str>, > source: TemplateSource, > ) -> Result, Error>; > + /// Load OAuth state for `endpoint_name`. > + /// > + /// The state file does not need to be locked, it is okay to just le= t the faster node "win" > + /// as long as the invariants documented by [`smtp::xoauth2::get_mic= rosoft_token`] and > + /// [`smtp::xoauth2::get_google_token`] hold, see those functions' d= oc comments for details. > + fn load_oauth_state(&self, endpoint_name: &str) -> Result; > + /// Save OAuth state `state` for `endpoint_name`. Passing `None` del= etes > + /// the state file for `endpoint_name`. > + /// > + /// The state file does not need to be locked, it is okay to just le= t the faster node "win" > + /// as long as the invariants documented by [`smtp::xoauth2::get_mic= rosoft_token`] and > + /// [`smtp::xoauth2::get_google_token`] hold, see those functions' d= oc comments for details. > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error>; > } > =20 > #[cfg(not(test))] > diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/conte= xt/pbs.rs > index 3e5da59c..6c82a469 100644 > --- a/proxmox-notify/src/context/pbs.rs > +++ b/proxmox-notify/src/context/pbs.rs > @@ -7,6 +7,7 @@ use proxmox_schema::{ObjectSchema, Schema, StringSchema}; > use proxmox_section_config::{SectionConfig, SectionConfigPlugin}; > =20 > use crate::context::{common, Context}; > +use crate::endpoints::smtp::State; This one here as well with cargo build --no-default-features --features=3Dpbs-context > use crate::renderer::TemplateSource; > use crate::Error; > =20 > @@ -125,6 +126,19 @@ impl Context for PBSContext { > .map_err(|err| Error::Generic(format!("could not load templa= te: {err}")))?; > Ok(template_string) > } > + > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D format!("/var/lib/proxmox-backup-priv/notifications= /state-{endpoint_name}.json"); > + State::load(path) > + } > + > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D format!("/var/lib/proxmox-backup-priv/notifications= /state-{endpoint_name}.json"); Sorry for the back and forth with the path. As discussed off-list, we now agreed on the following paths for the oauth state files: PBS: /var/lib/proxmox-backup/notifications/oauth-state/.json PDM: /var/lib/proxmox-datacenter-manager/notifications/oauth-state/.json PVE: /etc/pve/priv/notifications/oauth-state/.json > + match state { > + Some(s) =3D> s.save(path, nix::sys::stat::Mode::from_bits_tr= uncate(0o600)), > + None =3D> Ok(State::delete(path)), > + } > + } > } > =20 > #[cfg(test)] > diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/conte= xt/pve.rs > index a97cce26..28d9ab82 100644 > --- a/proxmox-notify/src/context/pve.rs > +++ b/proxmox-notify/src/context/pve.rs > @@ -1,7 +1,9 @@ > +use std::path::Path; > + > use crate::context::{common, Context}; > +use crate::endpoints::smtp::State; Also this one with cargo build --no-default-features --features=3Dpve-context > use crate::renderer::TemplateSource; > use crate::Error; > -use std::path::Path; > =20 > fn lookup_mail_address(content: &str, user: &str) -> Option { > common::normalize_for_return(content.lines().find_map(|line| { > @@ -74,6 +76,19 @@ impl Context for PVEContext { > .map_err(|err| Error::Generic(format!("could not load templa= te: {err}")))?; > Ok(template_string) > } > + > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D format!("/etc/pve/priv/notifications/state-{endpoin= t_name}.json"); > + State::load(path) > + } > + > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D format!("/etc/pve/priv/notifications/state-{endpoin= t_name}.json"); > + match state { > + Some(s) =3D> s.save(path, nix::sys::stat::Mode::from_bits_tr= uncate(0o600)), > + None =3D> Ok(State::delete(path)), > + } > + } > } > =20 > pub static PVE_CONTEXT: PVEContext =3D PVEContext; > diff --git a/proxmox-notify/src/context/test.rs b/proxmox-notify/src/cont= ext/test.rs > index 2c236b4c..d02f2990 100644 > --- a/proxmox-notify/src/context/test.rs > +++ b/proxmox-notify/src/context/test.rs > @@ -1,4 +1,5 @@ > use crate::context::Context; > +use crate::endpoints::smtp::State; > use crate::renderer::TemplateSource; > use crate::Error; > =20 > @@ -40,4 +41,17 @@ impl Context for TestContext { > ) -> Result, Error> { > Ok(Some(String::new())) > } > + > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D format!("/tmp/notifications/state-{endpoint_name}.j= son"); > + State::load(path) > + } > + > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D format!("/tmp/notifications/state-{endpoint_name}.j= son"); > + match state { > + Some(s) =3D> s.save(path, nix::sys::stat::Mode::from_bits_tr= uncate(0o750)), > + None =3D> Ok(State::delete(path)), > + } > + } > } > diff --git a/proxmox-notify/src/endpoints/smtp.rs b/proxmox-notify/src/en= dpoints/smtp.rs > index d1cdb540..172bcdba 100644 > --- a/proxmox-notify/src/endpoints/smtp.rs > +++ b/proxmox-notify/src/endpoints/smtp.rs > @@ -25,6 +25,8 @@ const SMTP_TIMEOUT: u16 =3D 5; > =20 > mod xoauth2; > =20 > +pub use xoauth2::State; > + > #[api] > #[derive(Debug, Serialize, Deserialize, Default, Clone, Copy)] > #[serde(rename_all =3D "kebab-case")] > diff --git a/proxmox-notify/src/endpoints/smtp/xoauth2.rs b/proxmox-notif= y/src/endpoints/smtp/xoauth2.rs > index 90ee630f..97ea46d8 100644 > --- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs > +++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs > @@ -1,10 +1,125 @@ > +use std::path::Path; > + > use oauth2::{ > basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, Re= freshToken, TokenResponse, > TokenUrl, > }; > +use serde::{Deserialize, Serialize}; > +use tracing::debug; > =20 > use crate::Error; > =20 > +#[derive(Serialize, Deserialize, Clone, Debug, Default)] > +#[serde(rename_all =3D "kebab-case")] No big deal, but this struct could use a doc comment. > +pub struct State { > + #[serde(skip_serializing_if =3D "Option::is_none")] > + pub oauth2_refresh_token: Option, > + pub last_refreshed: i64, > +} > + > +impl From> for State { > + fn from(value: Option) -> Self { > + Self { > + oauth2_refresh_token: value, > + last_refreshed: proxmox_time::epoch_i64(), > + } > + } > +} This `impl From` feels a bit off to me, due to it being not 100% functional (due to the timestamp). Also `From>` feels a bit odd to me. Maybe just have a `State::new` instead? In the end-result of this series, you only ever call this From in the api handler, and there only if the refresh-token is provided. I guess you could have a State::new(refresh_token: String, timestamp: i64) then? > + > +/// Attempt to create a directory at `path` with `mode`, returning `Ok((= ))` if the directory either already > +/// exists, or was successfully created. > +/// > +/// `pmxcfs` automatically sets the x-bit in directory permissions, howe= ver it does not allow the user > +/// to set it, the `fchmod` fails with `EPERM` on directories with `0o70= 0`. > +/// > +/// The `proxmox_sys` version of this function unconditionally logs mode= mismatches even with > +/// `enforce_permissions =3D=3D false` AND calls `fchmod`. This means th= at if using that version, we would > +/// always get a permission mismatch warning in the `pvedaemon` logs, ev= en though we do not need the > +/// `fchmod` call. > +fn ensure_dir_exists>(path: P, mode: nix::sys::stat::Mode= ) -> Result<(), Error> { > + match nix::unistd::mkdir(path.as_ref(), mode) { > + Ok(()) | Err(nix::errno::Errno::EEXIST) =3D> Ok(()), > + Err(e) =3D> Err(Error::StatePersistence( > + path.as_ref().to_string_lossy().into(), > + e.into(), > + )), > + } > +} > + > +impl State { > + /// Load the state for the endpoint identified by `endpoint_name`, i= nstantiating a default object > + /// if yes state exists. the 'yes' should not be here, I think? :) > + /// > + /// # Errors > + /// An [`Error`] is returned if deserialization of the state object = fails. > + pub(crate) fn load>(path: P) -> Result = { This one should be `pub`, since it is supposed to be called from the context implementation (which should be moved out of this crate at some point). > + let path_str =3D path.as_ref().to_string_lossy(); > + match proxmox_sys::fs::file_get_optional_contents(&path) > + .map_err(|e| Error::StateRetrieval(path_str.to_string(), e.i= nto()))? > + { > + Some(bytes) =3D> { > + debug!("loaded state file from {path_str}"); > + serde_json::from_slice(&bytes) > + .map_err(|e| Error::StateRetrieval(path_str.to_strin= g(), e.into())) > + } > + None =3D> { > + debug!( > + "no existing state file found for endpoint at {path_= str}, creating empty state" > + ); > + Ok(State::default()) > + } > + } > + } > + > + /// Persist the state for the endpoint identified by `endpoint_name`= . > + /// > + /// # Errors > + /// An [`Error`] is returned if serialization of the state object, o= r the final write, fail. > + pub(crate) fn save>( Same here. > + self, > + path: P, > + mode: nix::sys::stat::Mode, I'd rather use CreateOptions here and move the CreateOptions::new to the caller > + ) -> Result<(), Error> { > + let path_str =3D path.as_ref().to_string_lossy(); > + let parent =3D path.as_ref().parent().unwrap(); > + > + debug!("attempting to persist state at {path_str}"); > + > + ensure_dir_exists(parent, mode)?; > + > + let s =3D serde_json::to_string_pretty(&self) > + .map_err(|e| Error::StatePersistence(path_str.to_string(), e= .into()))?; > + > + proxmox_sys::fs::replace_file( > + &path, > + s.as_bytes(), > + proxmox_sys::fs::CreateOptions::new().perm(mode), > + false, > + ) > + .map_err(|e| Error::StatePersistence(path_str.to_string(), e.int= o())) > + } > + > + /// Delete the state for the endpoint identitied by `endpoint_name`. > + /// > + /// # Errors > + /// An [`Error`] is returned if the state file cannot be deleted. This seems to be wrong? > + pub(crate) fn delete>(path: P) { Same here with regards to `pub` > + let _ =3D std::fs::remove_file(&path); Might make sense to the following here (untested): if let Err(e) =3D std::fs::remove_file(&path) { if e.kind() !=3D std::io::ErrorKind::NotFound { // log error } } > + } > + > + /// Set `last_refreshed`. > + pub fn set_last_refreshed(mut self, last_refreshed: i64) -> Self { > + self.last_refreshed =3D last_refreshed; > + self > + } > + > + /// Set `oauth2_refresh_token`. > + pub fn set_oauth2_refresh_token(mut self, oauth2_refresh_token: Opti= on) -> Self { > + self.oauth2_refresh_token =3D oauth2_refresh_token; > + self > + } > +} > + > /// This newtype implements the `SyncHttpClient` trait for [`ureq::Agent= `]. This allows > /// us to avoid pulling in a different backend like `reqwest`. > /// > diff --git a/proxmox-notify/src/lib.rs b/proxmox-notify/src/lib.rs > index 879f8326..619dd7db 100644 > --- a/proxmox-notify/src/lib.rs > +++ b/proxmox-notify/src/lib.rs > @@ -41,6 +41,10 @@ pub enum Error { > FilterFailed(String), > /// The notification's template string could not be rendered > RenderError(Box), > + /// The state for an endpoint could not be persisted > + StatePersistence(String, Box), > + /// The state for an endpoint could not be retrieved > + StateRetrieval(String, Box), > /// Generic error for anything else > Generic(String), > } > @@ -70,6 +74,12 @@ impl Display for Error { > Error::FilterFailed(message) =3D> { > write!(f, "could not apply filter: {message}") > } > + Error::StatePersistence(path, err) =3D> { > + write!(f, "could not persist state at {path}: {err}") > + } > + Error::StateRetrieval(path, err) =3D> { > + write!(f, "could not retrieve state from {path}: {err}") > + } > Error::RenderError(err) =3D> write!(f, "could not render not= ification template: {err}"), > Error::Generic(message) =3D> f.write_str(message), > } > @@ -86,6 +96,8 @@ impl StdError for Error { > Error::TargetTestFailed(errs) =3D> Some(&*errs[0]), > Error::FilterFailed(_) =3D> None, > Error::RenderError(err) =3D> Some(&**err), > + Error::StatePersistence(_, err) =3D> Some(&**err), > + Error::StateRetrieval(_, err) =3D> Some(&**err), > Error::Generic(_) =3D> None, > } > }