[pmg-devel] [PATCH perl-rs 6/7] pmg: add tfa module

Wolfgang Bumiller w.bumiller at proxmox.com
Fri Nov 26 14:55:17 CET 2021


Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
 pmg-rs/Cargo.toml     |   5 +-
 pmg-rs/Makefile       |   5 +-
 pmg-rs/debian/control |   9 +-
 pmg-rs/src/lib.rs     |   1 +
 pmg-rs/src/tfa.rs     | 603 ++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 618 insertions(+), 5 deletions(-)
 create mode 100644 pmg-rs/src/tfa.rs

diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml
index 659456c..1d66bb5 100644
--- a/pmg-rs/Cargo.toml
+++ b/pmg-rs/Cargo.toml
@@ -21,13 +21,16 @@ crate-type = [ "cdylib" ]
 [dependencies]
 anyhow = "1.0"
 hex = "0.4"
+libc = "0.2"
+nix = "0.19"
 openssl = "0.10.32"
 serde = "1.0"
 serde_bytes = "0.11.3"
 serde_json = "1.0"
+url = "2"
 
 perlmod = { version = "0.9", features = [ "exporter" ] }
 
 proxmox-acme-rs = { version = "0.3.1", features = ["client"] }
-
 proxmox-apt = "0.8.0"
+proxmox-tfa = { version = "2", features = ["api"] }
diff --git a/pmg-rs/Makefile b/pmg-rs/Makefile
index a290544..69b2798 100644
--- a/pmg-rs/Makefile
+++ b/pmg-rs/Makefile
@@ -18,9 +18,10 @@ PM_DIRS := \
 	PMG/RS/APT
 
 PM_FILES := \
-	PMG/RS/Acme.pm \
 	PMG/RS/APT/Repositories.pm \
-	PMG/RS/CSR.pm
+	PMG/RS/Acme.pm \
+	PMG/RS/CSR.pm \
+	PMG/RS/TFA.pm
 
 ifeq ($(BUILD_MODE), release)
 CARGO_BUILD_ARGS += --release
diff --git a/pmg-rs/debian/control b/pmg-rs/debian/control
index be632ba..8c22fae 100644
--- a/pmg-rs/debian/control
+++ b/pmg-rs/debian/control
@@ -6,15 +6,20 @@ Build-Depends:
  debhelper (>= 12),
  librust-anyhow-1+default-dev,
  librust-hex-0.4+default-dev,
+ librust-libc-0.2+default-dev,
+ librust-nix-0.19+default-dev,
  librust-openssl-0.10+default-dev (>= 0.10.32-~~),
- librust-perlmod-0.8+default-dev,
- librust-perlmod-0.8+exporter-dev,
+ librust-perlmod-0.8+default-dev (>= 0.8.1-~~),
+ librust-perlmod-0.8+exporter-dev (>= 0.8.1-~~),
  librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~),
  librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~),
  librust-proxmox-apt-0.8+default-dev,
+ librust-proxmox-tfa-2+api-dev,
+ librust-proxmox-tfa-2+default-dev,
  librust-serde-1+default-dev,
  librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~),
  librust-serde-json-1+default-dev,
+ librust-url-2+default-dev,
 Standards-Version: 4.3.0
 Homepage: https://www.proxmox.com
 
diff --git a/pmg-rs/src/lib.rs b/pmg-rs/src/lib.rs
index 47e61b5..e3c9593 100644
--- a/pmg-rs/src/lib.rs
+++ b/pmg-rs/src/lib.rs
@@ -1,3 +1,4 @@
 pub mod acme;
 pub mod apt;
 pub mod csr;
+pub mod tfa;
diff --git a/pmg-rs/src/tfa.rs b/pmg-rs/src/tfa.rs
new file mode 100644
index 0000000..404ddb2
--- /dev/null
+++ b/pmg-rs/src/tfa.rs
@@ -0,0 +1,603 @@
+//! This implements the `tfa.cfg` parser & TFA API calls for PMG.
+//!
+//! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`.
+//! Contrary to the PVE implementation, this does not need to provide any backward compatible
+//! entries.
+//!
+//! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in
+//! order to avoid losing the rust magic-ref.
+
+use std::fs::File;
+use std::io::{self, Read};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, format_err, Error};
+use nix::errno::Errno;
+use nix::sys::stat::Mode;
+
+pub(self) use proxmox_tfa::api::{
+    RecoveryState, TfaChallenge, TfaConfig, TfaResponse, U2fConfig, WebauthnConfig,
+};
+
+#[perlmod::package(name = "PMG::RS::TFA")]
+mod export {
+    use std::convert::TryInto;
+    use std::sync::Mutex;
+
+    use anyhow::{bail, format_err, Error};
+    use serde_bytes::ByteBuf;
+    use url::Url;
+
+    use perlmod::Value;
+    use proxmox_tfa::api::methods;
+
+    use super::{TfaConfig, UserAccess};
+
+    perlmod::declare_magic!(Box<Tfa> : &Tfa as "PMG::RS::TFA");
+
+    /// A TFA Config instance.
+    pub struct Tfa {
+        inner: Mutex<TfaConfig>,
+    }
+
+    /// Prevent 'dclone'.
+    #[export(name = "STORABLE_freeze", raw_return)]
+    fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> {
+        bail!("freezing TFA config not supported!");
+    }
+
+    /// Parse a TFA configuration.
+    #[export(raw_return)]
+    fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
+        let mut inner: TfaConfig = serde_json::from_slice(config)
+            .map_err(|err| format_err!("failed to parse TFA file: {}", err))?;
+
+        // PMG does not support U2F.
+        inner.u2f = None;
+        Ok(perlmod::instantiate_magic!(
+            &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) })
+        ))
+    }
+
+    /// Write the configuration out into a JSON string.
+    #[export]
+    fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
+        let inner = this.inner.lock().unwrap();
+        Ok(ByteBuf::from(serde_json::to_vec(&*inner)?))
+    }
+
+    /// Debug helper: serialize the TFA user data into a perl value.
+    #[export]
+    fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
+        let inner = this.inner.lock().unwrap();
+        Ok(perlmod::to_value(&*inner)?)
+    }
+
+    /// Get a list of all the user names in this config.
+    /// PMG uses this to verify users and purge the invalid ones.
+    #[export]
+    fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.keys().cloned().collect())
+    }
+
+    /// Remove a user from the TFA configuration.
+    #[export]
+    fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
+        Ok(this.inner.lock().unwrap().users.remove(userid).is_some())
+    }
+
+    /// Get the TFA data for a specific user.
+    #[export(raw_return)]
+    fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
+        perlmod::to_value(&this.inner.lock().unwrap().users.get(userid))
+    }
+
+    /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
+    /// written out.
+    #[export]
+    fn add_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description)
+    }
+
+    /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
+    /// needs to be written out!
+    #[export]
+    fn finish_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response)
+    }
+
+    /// Check if a user has any TFA entries of a given type.
+    #[export]
+    fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
+        Ok(match this.inner.lock().unwrap().users.get(userid) {
+            Some(user) => match typename {
+                "totp" | "oath" => !user.totp.is_empty(),
+                "u2f" => !user.u2f.is_empty(),
+                "webauthn" => !user.webauthn.is_empty(),
+                "yubico" => !user.yubico.is_empty(),
+                "recovery" => match &user.recovery {
+                    Some(r) => r.count_available() > 0,
+                    None => false,
+                },
+                _ => bail!("unrecognized TFA type {:?}", typename),
+            },
+            None => false,
+        })
+    }
+
+    /// Generates a space separated list of yubico keys of this account.
+    #[export]
+    fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.get(userid).map(|user| {
+            user.enabled_yubico_entries()
+                .fold(String::new(), |mut s, k| {
+                    if !s.is_empty() {
+                        s.push(' ');
+                    }
+                    s.push_str(k);
+                    s
+                })
+        }))
+    }
+
+    #[export]
+    fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
+        this.inner.lock().unwrap().u2f = config;
+    }
+
+    #[export]
+    fn set_webauthn_config(
+        #[try_from_ref] this: &Tfa,
+        config: Option<super::WebauthnConfig>,
+    ) -> Result<(), Error> {
+        this.inner.lock().unwrap().webauthn = config.map(TryInto::try_into).transpose()?;
+        Ok(())
+    }
+
+    #[export]
+    fn get_webauthn_config(
+        #[try_from_ref] this: &Tfa,
+    ) -> Result<(Option<String>, Option<super::WebauthnConfig>), Error> {
+        Ok(match this.inner.lock().unwrap().webauthn.clone() {
+            Some(config) => (Some(hex::encode(&config.digest())), Some(config.into())),
+            None => (None, None),
+        })
+    }
+
+    #[export]
+    fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool {
+        match &this.inner.lock().unwrap().webauthn {
+            Some(wa) => wa.origin.is_some(),
+            None => false,
+        }
+    }
+
+    /// Create an authentication challenge.
+    ///
+    /// Returns the challenge as a json string.
+    /// Returns `undef` if no second factor is configured.
+    #[export]
+    fn authentication_challenge(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        origin: Option<Url>,
+    ) -> Result<Option<String>, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        match inner.authentication_challenge(
+            UserAccess::new(&raw_this)?,
+            userid,
+            origin.as_ref(),
+        )? {
+            Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)),
+            None => Ok(None),
+        }
+    }
+
+    /// Get the recovery state (suitable for a challenge object).
+    #[export]
+    fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
+        this.inner
+            .lock()
+            .unwrap()
+            .users
+            .get(userid)
+            .and_then(|user| {
+                let state = user.recovery_state();
+                state.is_available().then(move || state)
+            })
+    }
+
+    /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
+    /// it.
+    ///
+    /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
+    /// (to use up recovery keys!).
+    #[export]
+    fn authentication_verify(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str, //super::TfaChallenge,
+        response: &str,
+        origin: Option<Url>,
+    ) -> Result<bool, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let challenge: super::TfaChallenge = serde_json::from_str(challenge)?;
+        let response: super::TfaResponse = response.parse()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner
+            .verify(
+                UserAccess::new(&raw_this)?,
+                userid,
+                &challenge,
+                response,
+                origin.as_ref(),
+            )
+            .map(|save| save.needs_saving())
+    }
+
+    /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
+    #[export]
+    fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> {
+        let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?;
+        Ok(totp.time(std::time::SystemTime::now())?.to_string())
+    }
+
+    #[export]
+    fn api_list_user_tfa(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+    ) -> Result<Vec<methods::TypedTfaInfo>, Error> {
+        methods::list_user_tfa(&this.inner.lock().unwrap(), userid)
+    }
+
+    #[export]
+    fn api_get_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+    ) -> Option<methods::TypedTfaInfo> {
+        methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
+    }
+
+    /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
+    /// more tfa entries.
+    #[export]
+    fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
+        let mut this = this.inner.lock().unwrap();
+        match methods::delete_tfa(&mut this, userid, &id) {
+            Ok(has_entries_left) => Ok(has_entries_left),
+            Err(methods::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+
+    #[export]
+    fn api_list_tfa(
+        #[try_from_ref] this: &Tfa,
+        authid: &str,
+        top_level_allowed: bool,
+    ) -> Result<Vec<methods::TfaUser>, Error> {
+        methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
+    }
+
+    #[export]
+    fn api_add_tfa_entry(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: Option<String>,
+        totp: Option<String>,
+        value: Option<String>,
+        challenge: Option<String>,
+        ty: methods::TfaType,
+        origin: Option<Url>,
+    ) -> Result<methods::TfaUpdateInfo, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        methods::add_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            UserAccess::new(&raw_this)?,
+            userid,
+            description,
+            totp,
+            value,
+            challenge,
+            ty,
+            origin.as_ref(),
+        )
+    }
+
+    /// Add a totp entry without validating it, used for user.cfg keys.
+    /// Returns the ID.
+    #[export]
+    fn add_totp_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: String,
+        totp: String,
+    ) -> Result<String, Error> {
+        Ok(this
+            .inner
+            .lock()
+            .unwrap()
+            .add_totp(userid, description, totp.parse()?))
+    }
+
+    /// Add a yubico entry without validating it, used for user.cfg keys.
+    /// Returns the ID.
+    #[export]
+    fn add_yubico_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: String,
+        yubico: String,
+    ) -> String {
+        this.inner
+            .lock()
+            .unwrap()
+            .add_yubico(userid, description, yubico)
+    }
+
+    #[export]
+    fn api_update_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+        description: Option<String>,
+        enable: Option<bool>,
+    ) -> Result<(), Error> {
+        match methods::update_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            userid,
+            id,
+            description,
+            enable,
+        ) {
+            Ok(()) => Ok(()),
+            Err(methods::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+}
+
+/// Attach the path to errors from [`nix::mkir()`].
+pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> {
+    let path = path.as_ref();
+    match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) {
+        Ok(()) => Ok(()),
+        Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()),
+        Err(err) => bail!("failed to create directory {:?}: {}", path, err),
+    }
+}
+
+#[cfg(debug_assertions)]
+#[derive(Clone)]
+#[repr(transparent)]
+pub struct UserAccess(perlmod::Value);
+
+#[cfg(debug_assertions)]
+impl UserAccess {
+    #[inline]
+    fn new(value: &perlmod::Value) -> Result<Self, Error> {
+        value
+            .dereference()
+            .ok_or_else(|| format_err!("bad TFA config object"))
+            .map(Self)
+    }
+
+    #[inline]
+    fn is_debug(&self) -> bool {
+        self.0
+            .as_hash()
+            .and_then(|v| v.get("-debug"))
+            .map(|v| v.iv() != 0)
+            .unwrap_or(false)
+    }
+}
+
+#[cfg(not(debug_assertions))]
+#[derive(Clone, Copy)]
+#[repr(transparent)]
+pub struct UserAccess;
+
+#[cfg(not(debug_assertions))]
+impl UserAccess {
+    #[inline]
+    const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> {
+        Ok(Self)
+    }
+
+    #[inline]
+    const fn is_debug(&self) -> bool {
+        false
+    }
+}
+
+/// Build the path to the challenge data file for a user.
+fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
+    if debug {
+        PathBuf::from(format!("./local-tfa-challenges/{}", userid))
+    } else {
+        PathBuf::from(format!("/run/pmg-private/tfa-challenges/{}", userid))
+    }
+}
+
+impl proxmox_tfa::api::OpenUserChallengeData for UserAccess {
+    type Data = UserChallengeData;
+
+    fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
+        if self.is_debug() {
+            mkdir("./local-tfa-challenges", 0o700)?;
+        } else {
+            mkdir("/run/pmg-private", 0o700)?;
+            mkdir("/run/pmg-private/tfa-challenges", 0o700)?;
+        }
+
+        let path = challenge_data_path(userid, self.is_debug());
+
+        let mut file = std::fs::OpenOptions::new()
+            .create(true)
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+            .map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?;
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        // the file may be empty, so read to a temporary buffer first:
+        let mut data = Vec::with_capacity(4096);
+
+        file.read_to_end(&mut data).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        let inner = if data.is_empty() {
+            Default::default()
+        } else {
+            match serde_json::from_slice(&data) {
+                Ok(inner) => inner,
+                Err(err) => {
+                    eprintln!(
+                        "failed to parse challenge data for user {}: {}",
+                        userid, err
+                    );
+                    Default::default()
+                }
+            }
+        };
+
+        Ok(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        })
+    }
+
+    /// `open` without creating the file if it doesn't exist, to finish WA authentications.
+    fn open_no_create(&self, userid: &str) -> Result<Option<UserChallengeData>, Error> {
+        let path = challenge_data_path(userid, self.is_debug());
+
+        let mut file = match std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+        {
+            Ok(file) => file,
+            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
+            Err(err) => return Err(err.into()),
+        };
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        let inner = serde_json::from_reader(&mut file).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        Ok(Some(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        }))
+    }
+
+    fn remove(&self, userid: &str) -> Result<bool, Error> {
+        let path = challenge_data_path(userid, self.is_debug());
+        match std::fs::remove_file(&path) {
+            Ok(()) => Ok(true),
+            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+            Err(err) => Err(err.into()),
+        }
+    }
+}
+
+/// Container of `TfaUserChallenges` with the corresponding file lock guard.
+///
+/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
+/// challenges.
+pub struct UserChallengeData {
+    inner: proxmox_tfa::api::TfaUserChallenges,
+    path: PathBuf,
+    lock: File,
+}
+
+impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData {
+    fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges {
+        &mut self.inner
+    }
+
+    fn save(self) -> Result<(), Error> {
+        UserChallengeData::save(self)
+    }
+}
+
+impl UserChallengeData {
+    fn lock_file(fd: RawFd) -> Result<(), Error> {
+        let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
+
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to lock tfa user challenge data: {}", err);
+        }
+
+        Ok(())
+    }
+
+    /// Rewind & truncate the file for an update.
+    fn rewind(&mut self) -> Result<(), Error> {
+        use std::io::{Seek, SeekFrom};
+
+        let pos = self.lock.seek(SeekFrom::Start(0))?;
+        if pos != 0 {
+            bail!(
+                "unexpected result trying to rewind file, position is {}",
+                pos
+            );
+        }
+
+        let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) };
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to truncate challenge data: {}", err);
+        }
+
+        Ok(())
+    }
+
+    /// Save the current data. Note that we do not replace the file here since we lock the file
+    /// itself, as it is in `/run`, and the typical error case for this particular situation
+    /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
+    /// other reasons then...
+    ///
+    /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
+    /// way also unlocks early.
+    fn save(mut self) -> Result<(), Error> {
+        self.rewind()?;
+
+        serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
+            format_err!("failed to update challenge file {:?}: {}", self.path, err)
+        })?;
+
+        Ok(())
+    }
+}
-- 
2.30.2





More information about the pmg-devel mailing list