From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id C86C31FF13F for ; Thu, 23 Apr 2026 14:25:45 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 9814215D0F; Thu, 23 Apr 2026 14:25:45 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Thu, 23 Apr 2026 14:24:37 +0200 Message-Id: To: "Arthur Bied-Charreton" , , Subject: Re: [PATCH proxmox v4 03/24] notify: smtp: Introduce state management X-Mailer: aerc 0.20.0 References: <20260421115957.402589-1-a.bied-charreton@proxmox.com> <20260421115957.402589-4-a.bied-charreton@proxmox.com> In-Reply-To: <20260421115957.402589-4-a.bied-charreton@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1776946989043 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.121 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: LOWEEX6IEGOXUP2PRUSBDBUMVHPSQY5B X-Message-ID-Hash: LOWEEX6IEGOXUP2PRUSBDBUMVHPSQY5B X-MailFrom: s.sterz@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: On Tue Apr 21, 2026 at 1:59 PM CEST, 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 public in order 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 used in the > Context implementations. > > Signed-off-by: Arthur Bied-Charreton > Reviewed-by: Lukas Wagner > --- > proxmox-notify/Cargo.toml | 12 ++- > proxmox-notify/debian/control | 41 +++------ > proxmox-notify/src/context/mod.rs | 17 ++++ > proxmox-notify/src/context/pbs.rs | 23 +++++ > proxmox-notify/src/context/pve.rs | 25 ++++- > proxmox-notify/src/context/test.rs | 22 +++++ > proxmox-notify/src/endpoints/smtp.rs | 2 + > proxmox-notify/src/endpoints/smtp/xoauth2.rs | 97 ++++++++++++++++++++ > proxmox-notify/src/lib.rs | 12 +++ > 9 files changed, 218 insertions(+), 33 deletions(-) > > diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml > index c0d9921b..873a7d6f 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 } > + > > [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 1b5c4068..b8db398c 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-~~) , > @@ -51,6 +52,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, > @@ -60,6 +62,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-~~), > @@ -73,14 +76,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" > > @@ -126,8 +136,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}), > @@ -136,35 +145,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= . > > -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..a3942e5f 100644 > --- a/proxmox-notify/src/context/mod.rs > +++ b/proxmox-notify/src/context/mod.rs > @@ -1,6 +1,8 @@ > use std::fmt::Debug; > use std::sync::Mutex; > > +#[cfg(feature =3D "smtp")] > +use crate::endpoints::smtp::State; > use crate::renderer::TemplateSource; > use crate::Error; > > @@ -32,6 +34,21 @@ 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. > + #[cfg(feature =3D "smtp")] > + 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. > + #[cfg(feature =3D "smtp")] > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error>; i'm not sure the no-locking approach here works. if i understand correctly, the following example is possible: A: calls `trigger_state_refresh`, does not lock the notification config B: calls `delete_smtp_endpoint`, locks the notification config A: reads the refresh token B: removes the endpoint and state file A: finishes refreshing the token and safes it out if we extend that example with a C that adds an endpoint with the same name after B finishes, then it would suddenly be left with a state file from the previous endpoint. similar examples can also happen when calling `build_transport` since that also includes a read & write cycle of the state file. if the state file were locked properly, B would fail to acquire the lock when trying to delete it (or have to wait until A finishes). if this behaviour is fine, maybe this would benefit from some documentation. > } > > #[cfg(not(test))] > diff --git a/proxmox-notify/src/context/pbs.rs b/proxmox-notify/src/conte= xt/pbs.rs > index 3e5da59c..8c5fce6b 100644 > --- a/proxmox-notify/src/context/pbs.rs > +++ b/proxmox-notify/src/context/pbs.rs > @@ -1,5 +1,6 @@ > use std::path::Path; > > +use proxmox_sys::fs::CreateOptions; > use serde::Deserialize; > use tracing::error; > > @@ -7,6 +8,8 @@ use proxmox_schema::{ObjectSchema, Schema, StringSchema}; > use proxmox_section_config::{SectionConfig, SectionConfigPlugin}; > > use crate::context::{common, Context}; > +#[cfg(feature =3D "smtp")] > +use crate::endpoints::smtp::State; > use crate::renderer::TemplateSource; > use crate::Error; > > @@ -125,6 +128,26 @@ impl Context for PBSContext { > .map_err(|err| Error::Generic(format!("could not load templa= te: {err}")))?; > Ok(template_string) > } > + > + #[cfg(feature =3D "smtp")] > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D > + format!("/var/lib/proxmox-backup/notifications/oauth-state/{= endpoint_name}.json"); > + State::load(path) > + } > + > + #[cfg(feature =3D "smtp")] > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D > + format!("/var/lib/proxmox-backup/notifications/oauth-state/{= endpoint_name}.json"); > + match state { > + Some(s) =3D> s.save( > + path, > + CreateOptions::new().perm(nix::sys::stat::Mode::from_bit= s_truncate(0o600)), > + ), > + None =3D> Ok(State::delete(path)), > + } > + } > } > > #[cfg(test)] > diff --git a/proxmox-notify/src/context/pve.rs b/proxmox-notify/src/conte= xt/pve.rs > index a97cce26..2befd53b 100644 > --- a/proxmox-notify/src/context/pve.rs > +++ b/proxmox-notify/src/context/pve.rs > @@ -1,7 +1,12 @@ > +use std::path::Path; > + > +use proxmox_sys::fs::CreateOptions; > + > use crate::context::{common, Context}; > +#[cfg(feature =3D "smtp")] > +use crate::endpoints::smtp::State; > use crate::renderer::TemplateSource; > use crate::Error; > -use std::path::Path; > > fn lookup_mail_address(content: &str, user: &str) -> Option { > common::normalize_for_return(content.lines().find_map(|line| { > @@ -74,6 +79,24 @@ impl Context for PVEContext { > .map_err(|err| Error::Generic(format!("could not load templa= te: {err}")))?; > Ok(template_string) > } > + > + #[cfg(feature =3D "smtp")] > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D format!("/etc/pve/priv/notifications/oauth-state/{e= ndpoint_name}.json"); > + State::load(path) > + } > + > + #[cfg(feature =3D "smtp")] > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D format!("/etc/pve/priv/notifications/oauth-state/{e= ndpoint_name}.json"); > + match state { > + Some(s) =3D> s.save( > + path, > + CreateOptions::new().perm(nix::sys::stat::Mode::from_bit= s_truncate(0o600)), > + ), > + None =3D> Ok(State::delete(path)), > + } > + } > } > > 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..9a653343 100644 > --- a/proxmox-notify/src/context/test.rs > +++ b/proxmox-notify/src/context/test.rs > @@ -1,4 +1,8 @@ > +use proxmox_sys::fs::CreateOptions; > + > use crate::context::Context; > +#[cfg(feature =3D "smtp")] > +use crate::endpoints::smtp::State; > use crate::renderer::TemplateSource; > use crate::Error; > > @@ -40,4 +44,22 @@ impl Context for TestContext { > ) -> Result, Error> { > Ok(Some(String::new())) > } > + > + #[cfg(feature =3D "smtp")] > + fn load_oauth_state(&self, endpoint_name: &str) -> Result { > + let path =3D format!("/tmp/notifications/oauth-state/{endpoint_n= ame}.json"); > + State::load(path) > + } > + > + #[cfg(feature =3D "smtp")] > + fn save_oauth_state(&self, endpoint_name: &str, state: Option= ) -> Result<(), Error> { > + let path =3D format!("/tmp/notifications/oauth-state/{endpoint_n= ame}.json"); > + match state { > + Some(s) =3D> s.save( > + path, > + CreateOptions::new().perm(nix::sys::stat::Mode::from_bit= s_truncate(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; > > mod xoauth2; > > +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 06da0e79..1df5b447 100644 > --- a/proxmox-notify/src/endpoints/smtp/xoauth2.rs > +++ b/proxmox-notify/src/endpoints/smtp/xoauth2.rs > @@ -1,10 +1,107 @@ > +use std::path::Path; > + > use oauth2::{ > basic::BasicClient, AccessToken, AuthUrl, ClientId, ClientSecret, Re= freshToken, TokenResponse, > TokenUrl, > }; > +use serde::{Deserialize, Serialize}; > +use tracing::{debug, error}; > > use crate::Error; > > +#[derive(Serialize, Deserialize, Clone, Debug, Default)] > +#[serde(rename_all =3D "kebab-case")] > +/// Persistent state for XOAUTH2 SMTP endpoints. > +pub struct State { > + /// OAuth2 refresh token for this endpoint. > + #[serde(skip_serializing_if =3D "Option::is_none")] > + pub oauth2_refresh_token: Option, > + /// Unix timestamp (seconds) of the last successful token refresh. > + pub last_refreshed: i64, > +} > + > +impl State { > + /// Instantiate a new [`State`]. `last_refreshed` is expected to be = the UNIX > + /// timestamp (seconds) of the instantiation time. > + pub fn new(refresh_token: String, last_refreshed: i64) -> Self { > + Self { > + oauth2_refresh_token: Some(refresh_token), > + last_refreshed, > + } > + } > + > + /// Load state from `path` instantiating a default object if no stat= e exists. > + /// > + /// # Errors > + /// An [`Error`] is returned if deserialization of the state object = fails. > + pub fn load>(path: P) -> Result { > + 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 at `path` with `options`. > + /// > + /// # Errors > + /// An [`Error`] is returned if serialization of the state object, o= r the final write, fail. > + pub fn save>( > + self, > + path: P, > + options: proxmox_sys::fs::CreateOptions, > + ) -> Result<(), Error> { > + let path_str =3D path.as_ref().to_string_lossy(); > + let parent =3D path.as_ref().parent().unwrap(); > + imo returning an error here might be nicer. while this isn't likely to happen, this would panic if called with `path` set to an empty string or similar. if you want to not error out here, at least turn that into an `expect` and document the panic in the comment above. side note: thanks for the extensive documentation here in general! > + debug!("attempting to persist state at {path_str}"); > + > + proxmox_sys::fs::create_path(parent, Some(options), Some(options= )) > + .map_err(|e| Error::StatePersistence(path_str.to_string(), e= .into()))?; > + > + 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(), options, fals= e) > + .map_err(|e| Error::StatePersistence(path_str.to_string(), e= .into())) > + } > + > + /// Delete the state file at `path`. > + /// > + /// Errors are logged but not propagated. > + pub fn delete>(path: P) { > + if let Err(e) =3D std::fs::remove_file(&path) > + && e.kind() !=3D std::io::ErrorKind::NotFound > + { > + let path_str =3D path.as_ref().to_string_lossy(); > + error!("could not delete state file at {path_str}: {e}"); > + } > + } > + > + /// 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, > } > }