Skip to main content

toride_ssh_key/
lib.rs

1mod generate;
2pub mod install;
3mod inventory;
4mod repair;
5
6pub use install::InstallOutcome;
7pub use install::UninstallOutcome;
8
9use std::ffi::OsStr;
10
11use toride_ssh_core::SshPaths;
12use toride_ssh_core::{Error, KeyCreateParams, KeyDeleteParams, KeyFormat, Result, SshKey};
13
14const MAX_KEY_NAME_LENGTH: usize = 255;
15
16#[cfg(unix)]
17pub(crate) fn get_permissions(path: &std::path::Path) -> Option<toride_ssh_core::Permissions> {
18    use std::os::unix::fs::PermissionsExt;
19    let metadata = std::fs::metadata(path).ok()?;
20    let mode = metadata.permissions().mode();
21    Some(toride_ssh_core::Permissions {
22        mode: mode & 0o7777,
23    })
24}
25
26#[cfg(not(unix))]
27pub(crate) fn get_permissions(_path: &std::path::Path) -> Option<toride_ssh_core::Permissions> {
28    None
29}
30
31fn validate_key_name(name: &str) -> Result<()> {
32    if name.is_empty() {
33        return Err(Error::InvalidKeyName(
34            "key name must not be empty".to_owned(),
35        ));
36    }
37    if name.len() > MAX_KEY_NAME_LENGTH {
38        return Err(Error::InvalidKeyName(format!(
39            "key name must not exceed {MAX_KEY_NAME_LENGTH} bytes"
40        )));
41    }
42    if name.contains('\0') {
43        return Err(Error::InvalidKeyName(
44            "key name must not contain null bytes".to_owned(),
45        ));
46    }
47    if name.contains('/') || name.contains('\\') {
48        return Err(Error::InvalidKeyName(
49            "key name must not contain path separators".to_owned(),
50        ));
51    }
52    if name.contains("..") {
53        return Err(Error::InvalidKeyName(
54            "key name must not contain '..'".to_owned(),
55        ));
56    }
57    Ok(())
58}
59
60fn unique_backup_path(base: &std::path::Path) -> std::path::PathBuf {
61    if !base.exists() {
62        return base.to_path_buf();
63    }
64    let ts = std::time::SystemTime::now()
65        .duration_since(std::time::UNIX_EPOCH)
66        .unwrap_or_default()
67        .as_secs();
68    let ext = match base.extension() {
69        Some(e) => format!("{}.{}", e.to_string_lossy(), ts),
70        None => ts.to_string(),
71    };
72    base.with_extension(ext)
73}
74
75pub struct KeyService<'a> {
76    paths: &'a SshPaths,
77    runner: &'a dyn toride_ssh_core::CliRunner,
78}
79
80/// Inspect a private key file, memoized on its `(mtime, len)` stamp (rewrites re-parse).
81/// # Errors
82/// Same as the uncached parse; read errors are never memoized.
83pub fn inspect_key_cached(path: &std::path::Path) -> Result<SshKey> {
84    inventory::inspect_private_key_cached(path)
85}
86
87impl<'a> KeyService<'a> {
88    pub fn new(paths: &'a SshPaths, runner: &'a dyn toride_ssh_core::CliRunner) -> Self {
89        Self { paths, runner }
90    }
91
92    /// List all SSH keys on disk and in the agent; unparseable keys are skipped.
93    /// # Errors
94    /// [`Error::TaskFailed`] if the background scan task panics or is cancelled.
95    pub async fn list(&self) -> Result<Vec<SshKey>> {
96        inventory::scan_keys(self.paths, Some(self.runner)).await
97    }
98
99    /// Generate a new SSH key pair.
100    /// # Errors
101    /// [`Error::InvalidKeyName`] (bad name), [`Error::ToolNotFound`], [`Error::CommandFailed`].
102    pub async fn create(&self, params: KeyCreateParams) -> Result<SshKey> {
103        validate_key_name(&params.name)?;
104        generate::generate_key(self.paths, params, self.runner).await
105    }
106
107    /// Delete a key and optionally its companions, agent entry, and config refs.
108    /// # Errors
109    /// [`Error::KeyNotFound`], [`Error::InvalidKeyName`], [`Error::Io`], [`Error::TaskFailed`], [`Error::ConfigWriteFailed`].
110    pub async fn delete(&self, params: KeyDeleteParams) -> Result<()> {
111        validate_key_name(&params.name)?;
112        let private_path = self.paths.ssh_dir().join(&params.name);
113
114        if !private_path.exists() {
115            return Err(Error::KeyNotFound(params.name.clone()));
116        }
117
118        let public_path = private_path.with_extension("pub");
119
120        let cert_path = self
121            .paths
122            .ssh_dir()
123            .join(format!("{}-cert.pub", params.name));
124
125        let backup = params.backup;
126        let remove_public = params.remove_public;
127        let remove_certificate = params.remove_certificate;
128
129        if params.remove_from_agent {
130            remove_key_from_agent(&self.paths.ssh_dir().join(&params.name), self.runner).await;
131        }
132
133        tokio::task::spawn_blocking(move || {
134            if backup {
135                let backup_path = unique_backup_path(&private_path.with_extension("bak"));
136                std::fs::rename(&private_path, &backup_path)?;
137
138                if remove_public && public_path.exists() {
139                    let stem = public_path
140                        .file_stem()
141                        .unwrap_or_else(|| OsStr::new(""))
142                        .to_string_lossy();
143                    let pub_backup_base = public_path.with_file_name(format!("{stem}.pub.bak"));
144                    let pub_backup = unique_backup_path(&pub_backup_base);
145                    if let Err(e) = std::fs::rename(&public_path, &pub_backup) {
146                        tracing::warn!("failed to backup {}: {e}", public_path.display());
147                    }
148                }
149
150                if remove_certificate && cert_path.exists() {
151                    let name = cert_path
152                        .file_name()
153                        .unwrap_or_else(|| OsStr::new(""))
154                        .to_string_lossy();
155                    let cert_backup_base = cert_path.with_file_name(format!("{name}.bak"));
156                    let cert_backup = unique_backup_path(&cert_backup_base);
157                    if let Err(e) = std::fs::rename(&cert_path, &cert_backup) {
158                        tracing::warn!("failed to backup {}: {e}", cert_path.display());
159                    }
160                }
161            } else {
162                std::fs::remove_file(&private_path)?;
163
164                if remove_public && public_path.exists() {
165                    std::fs::remove_file(&public_path)?;
166                }
167
168                if remove_certificate && cert_path.exists() {
169                    std::fs::remove_file(&cert_path)?;
170                }
171            }
172
173            Ok::<(), Error>(())
174        })
175        .await
176        .map_err(|e| Error::TaskFailed(format!("delete task failed: {e}")))??;
177
178        if params.remove_from_config {
179            remove_from_config(self.paths, &params.name).await?;
180        }
181
182        Ok(())
183    }
184
185    /// Derive the `.pub` from a private key (encrypted keys via `ssh-keygen -y`).
186    /// # Errors
187    /// [`Error::KeyNotFound`], [`Error::ToolNotFound`], or [`Error::CommandFailed`].
188    pub async fn repair_public(
189        &self,
190        private_key_path: &std::path::Path,
191        passphrase: Option<&str>,
192    ) -> Result<()> {
193        repair::repair_public_key(private_key_path, passphrase, self.runner).await
194    }
195
196    /// Rename a key pair and companions; config refs NOT updated; companion failures non-fatal.
197    /// # Errors
198    /// [`Error::KeyNotFound`], [`Error::KeyExists`], [`Error::InvalidKeyName`], [`Error::Io`].
199    pub async fn rename(&self, old_name: &str, new_name: &str) -> Result<()> {
200        validate_key_name(old_name)?;
201        validate_key_name(new_name)?;
202
203        let old_private = self.paths.ssh_dir().join(old_name);
204        let new_private = self.paths.ssh_dir().join(new_name);
205
206        if !old_private.exists() {
207            return Err(Error::KeyNotFound(old_name.to_owned()));
208        }
209        if new_private.exists() {
210            return Err(Error::KeyExists(new_name.to_owned()));
211        }
212
213        let old_public = old_private.with_extension("pub");
214        let new_public = new_private.with_extension("pub");
215        let old_cert = self.paths.ssh_dir().join(format!("{old_name}-cert.pub"));
216        let new_cert = self.paths.ssh_dir().join(format!("{new_name}-cert.pub"));
217
218        tokio::task::spawn_blocking(move || {
219            std::fs::rename(&old_private, &new_private).map_err(Error::Io)?;
220
221            if old_public.exists()
222                && let Err(e) = std::fs::rename(&old_public, &new_public)
223            {
224                tracing::warn!("failed to rename public key: {e}");
225            }
226
227            if old_cert.exists()
228                && let Err(e) = std::fs::rename(&old_cert, &new_cert)
229            {
230                tracing::warn!("failed to rename certificate: {e}");
231            }
232
233            Ok(())
234        })
235        .await
236        .map_err(|e| Error::TaskFailed(format!("rename task failed: {e}")))?
237    }
238
239    /// Fix key file permissions (private `0o600`, public `0o644`).
240    /// # Errors
241    /// [`Error::KeyNotFound`], [`Error::InvalidKeyName`], or [`Error::Io`].
242    pub async fn chmod_fix(&self, key_name: &str) -> Result<()> {
243        validate_key_name(key_name)?;
244
245        let private_path = self.paths.ssh_dir().join(key_name);
246        if !private_path.exists() {
247            return Err(Error::KeyNotFound(key_name.to_owned()));
248        }
249
250        let public_path = private_path.with_extension("pub");
251
252        tokio::task::spawn_blocking(move || {
253            #[cfg(unix)]
254            {
255                use std::os::unix::fs::PermissionsExt;
256                std::fs::set_permissions(&private_path, std::fs::Permissions::from_mode(0o600))
257                    .map_err(Error::Io)?;
258
259                if public_path.exists()
260                    && let Err(e) = std::fs::set_permissions(
261                        &public_path,
262                        std::fs::Permissions::from_mode(0o644),
263                    )
264                {
265                    tracing::warn!("failed to set public key permissions: {e}");
266                }
267            }
268            #[cfg(not(unix))]
269            {
270                let _ = (private_path, public_path);
271            }
272            Ok(())
273        })
274        .await
275        .map_err(|e| Error::TaskFailed(format!("chmod task failed: {e}")))?
276    }
277
278    /// Change a key's passphrase (`None`/empty new removes it) via `SSH_ASKPASS`, never argv.
279    /// # Errors
280    /// [`Error::KeyNotFound`] or [`Error::CommandFailed`] (e.g. wrong old passphrase).
281    pub async fn change_passphrase(
282        &self,
283        key_path: &std::path::Path,
284        old_passphrase: Option<&str>,
285        new_passphrase: Option<&str>,
286    ) -> Result<()> {
287        if !key_path.exists() {
288            return Err(Error::KeyNotFound(key_path.display().to_string()));
289        }
290
291        let path_str = key_path
292            .to_str()
293            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
294            .to_owned();
295
296        let old_pass = old_passphrase.unwrap_or("").to_owned();
297        let new_pass = new_passphrase.unwrap_or("").to_owned();
298
299        let askpass = MultiAskpassHandler::new(&[&old_pass, &new_pass, &new_pass])?;
300        let args = vec!["-p".to_owned(), "-f".to_owned(), path_str];
301        run_with_askpass(self.runner, "ssh-keygen", args, &askpass).await?;
302
303        Ok(())
304    }
305
306    /// Change a key's comment (`ssh-keygen -c`); a passphrase goes via `SSH_ASKPASS`, never argv.
307    /// # Errors
308    /// [`Error::KeyNotFound`] or [`Error::CommandFailed`].
309    pub async fn change_comment(
310        &self,
311        key_path: &std::path::Path,
312        new_comment: &str,
313        passphrase: Option<&str>,
314    ) -> Result<()> {
315        if !key_path.exists() {
316            return Err(Error::KeyNotFound(key_path.display().to_string()));
317        }
318
319        let path_str = key_path
320            .to_str()
321            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
322            .to_owned();
323
324        let pass = passphrase.unwrap_or("");
325
326        let args = vec![
327            "-c".to_owned(),
328            "-f".to_owned(),
329            path_str,
330            "-C".to_owned(),
331            new_comment.to_owned(),
332        ];
333
334        if pass.is_empty() {
335            self.runner.run("ssh-keygen", args).await?;
336        } else {
337            let askpass = toride_ssh_agent::AskpassHandler::new(pass)?;
338            run_with_askpass(self.runner, "ssh-keygen", args, &askpass).await?;
339        }
340        Ok(())
341    }
342
343    /// Convert a key between OpenSSH and PEM formats, returning the content.
344    /// # Errors
345    /// [`Error::KeyNotFound`], [`Error::ToolNotFound`], or [`Error::CommandFailed`].
346    pub async fn convert(
347        &self,
348        key_path: &std::path::Path,
349        target_format: KeyFormat,
350    ) -> Result<String> {
351        if !key_path.exists() {
352            return Err(Error::KeyNotFound(key_path.display().to_string()));
353        }
354
355        if !self.runner.tool_exists("ssh-keygen") {
356            return Err(Error::ToolNotFound("ssh-keygen".to_owned()));
357        }
358
359        let path_str = key_path
360            .to_str()
361            .ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
362            .to_owned();
363
364        let args = match target_format {
365            KeyFormat::Pem => vec![
366                "-e".to_owned(),
367                "-m".to_owned(),
368                "PEM".to_owned(),
369                "-f".to_owned(),
370                path_str,
371            ],
372            KeyFormat::OpenSSH => vec![
373                "-i".to_owned(),
374                "-m".to_owned(),
375                "PEM".to_owned(),
376                "-f".to_owned(),
377                path_str,
378            ],
379        };
380
381        self.runner.run("ssh-keygen", args).await
382    }
383
384    /// Install a public key to a remote's `authorized_keys` via `ssh-copy-id` (or plain SSH).
385    /// # Errors
386    /// [`Error::ToolNotFound`], [`Error::CommandFailed`], or [`Error::KeyNotFound`].
387    pub async fn install_key_to_remote(
388        &self,
389        key_path: &std::path::Path,
390        dest: &str,
391    ) -> Result<install::InstallOutcome> {
392        install::install_key_to_remote(key_path, dest, self.runner).await
393    }
394
395    /// Remove a public key from a remote host's `authorized_keys`.
396    /// # Errors
397    /// [`Error::KeyNotFound`], [`Error::ToolNotFound`], or [`Error::CommandFailed`].
398    pub async fn uninstall_key_from_remote(
399        &self,
400        key_path: &std::path::Path,
401        dest: &str,
402    ) -> Result<install::UninstallOutcome> {
403        install::uninstall_key_from_remote(key_path, dest, self.runner).await
404    }
405}
406
407pub(crate) trait Askpass {
408    fn script_path(&self) -> &std::path::Path;
409}
410
411impl Askpass for toride_ssh_agent::AskpassHandler {
412    fn script_path(&self) -> &std::path::Path {
413        toride_ssh_agent::AskpassHandler::script_path(self)
414    }
415}
416
417pub(crate) async fn run_with_askpass(
418    runner: &dyn toride_ssh_core::CliRunner,
419    cmd: &str,
420    args: Vec<String>,
421    askpass: &dyn Askpass,
422) -> Result<String> {
423    let env = vec![
424        (
425            "SSH_ASKPASS".to_owned(),
426            askpass.script_path().to_string_lossy().into_owned(),
427        ),
428        ("SSH_ASKPASS_REQUIRE".to_owned(), "force".to_owned()),
429        ("DISPLAY".to_owned(), ":0".to_owned()),
430    ];
431    runner.run_with_env(cmd, args, env).await
432}
433
434struct MultiAskpassHandler {
435    script_path: std::path::PathBuf,
436}
437
438impl MultiAskpassHandler {
439    fn new(responses: &[&str]) -> Result<Self> {
440        #[cfg(unix)]
441        use std::io::Write;
442        #[cfg(unix)]
443        use std::os::unix::fs::OpenOptionsExt;
444
445        use std::fmt::Write as _;
446        let mut arms = String::new();
447        for (i, resp) in responses.iter().enumerate() {
448            let arm = i + 1;
449            let escaped = resp.replace('\'', "'\\''");
450            let _ = writeln!(arms, "    {arm}) echo '{escaped}';;");
451        }
452        let last_escaped = responses
453            .last()
454            .map(|r| r.replace('\'', "'\\''"))
455            .unwrap_or_default();
456
457        let dir = std::env::temp_dir();
458        let ts = std::time::SystemTime::now()
459            .duration_since(std::time::UNIX_EPOCH)
460            .unwrap_or_default()
461            .as_nanos();
462        let pid = std::process::id();
463        let tid = format!("{:?}", std::thread::current().id())
464            .replace("ThreadId(", "")
465            .replace(')', "");
466        let filename = format!("toride-askpass-multi-{pid}-{tid}-{ts}");
467
468        // Publish via hidden temp + atomic rename: an in-place rewrite can be seen
469        // half-written or open-for-writing (ETXTBSY); the single O_EXCL open carries
470        // the final 0o700 mode (open(2)).
471        #[cfg(unix)]
472        let tmp_path = dir.join(format!("{filename}.tmp"));
473        let script_path = dir.join(&filename);
474        let count_path = dir.join(format!("{filename}.cnt"));
475
476        let count_path_str = count_path.to_string_lossy().replace('\'', "'\\''");
477        let script = format!(
478            "#!/bin/sh\n\
479             # Generated by toride-ssh-key: answers SSH_ASKPASS prompts in order.\n\
480             n=$(cat '{count_path_str}' 2>/dev/null || echo 0)\n\
481             n=$((n+1))\n\
482             printf '%s' \"$n\" >'{count_path_str}'\n\
483             case \"$n\" in\n\
484             {arms}\
485             *) echo '{last_escaped}';;\n\
486             esac\n"
487        );
488
489        #[cfg(unix)]
490        {
491            let mut file = std::fs::OpenOptions::new()
492                .write(true)
493                .create_new(true)
494                .mode(0o700)
495                .open(&tmp_path)
496                .map_err(|e| {
497                    Error::CommandFailed(format!(
498                        "failed to create multi-askpass script {}: {e}",
499                        tmp_path.display()
500                    ))
501                })?;
502            file.write_all(script.as_bytes()).map_err(|e| {
503                Error::CommandFailed(format!(
504                    "failed to write multi-askpass script {}: {e}",
505                    tmp_path.display()
506                ))
507            })?;
508            let _ = file.sync_all();
509            drop(file);
510            std::fs::rename(&tmp_path, &script_path).map_err(|e| {
511                let _ = std::fs::remove_file(&tmp_path);
512                Error::CommandFailed(format!(
513                    "failed to publish multi-askpass script {}: {e}",
514                    script_path.display()
515                ))
516            })?;
517        }
518
519        #[cfg(not(unix))]
520        {
521            std::fs::write(&script_path, script.as_bytes()).map_err(|e| {
522                Error::CommandFailed(format!(
523                    "failed to write multi-askpass script {}: {e}",
524                    script_path.display()
525                ))
526            })?;
527        }
528
529        Ok(Self { script_path })
530    }
531
532    fn script_path(&self) -> &std::path::Path {
533        &self.script_path
534    }
535}
536
537impl Askpass for MultiAskpassHandler {
538    fn script_path(&self) -> &std::path::Path {
539        self.script_path()
540    }
541}
542
543impl Drop for MultiAskpassHandler {
544    fn drop(&mut self) {
545        if let Err(e) = std::fs::remove_file(&self.script_path) {
546            tracing::warn!(
547                "failed to remove multi-askpass script {}: {e}",
548                self.script_path.display()
549            );
550        }
551        let count_path = self.script_path.with_extension("cnt");
552        let _ = std::fs::remove_file(&count_path);
553    }
554}
555
556async fn remove_key_from_agent(
557    private_path: &std::path::Path,
558    runner: &dyn toride_ssh_core::CliRunner,
559) {
560    let Some(path_str) = private_path.to_str().map(str::to_owned) else {
561        tracing::warn!("invalid key path for ssh-add, skipping agent removal");
562        return;
563    };
564
565    if let Err(e) = runner.run("ssh-add", vec!["-d".to_owned(), path_str]).await {
566        tracing::warn!("ssh-add -d failed (key may not be in agent): {e}");
567    }
568}
569
570fn filter_config_lines(content: &str, ssh_dir_str: &str, key_name: &str) -> String {
571    let key_pattern_tilde = format!("~/.ssh/{key_name}");
572    let key_pattern_abs = format!("{ssh_dir_str}/{key_name}");
573
574    let cert_name = format!("{key_name}-cert.pub");
575    let cert_pattern_tilde = format!("~/.ssh/{cert_name}");
576    let cert_pattern_abs = format!("{ssh_dir_str}/{cert_name}");
577
578    let trailing_newline = content.ends_with('\n');
579    let line_ending = if content.contains("\r\n") {
580        "\r\n"
581    } else {
582        "\n"
583    };
584
585    let new_content: String = content
586        .lines()
587        .filter(|line| {
588            let trimmed = line.trim();
589            let keyword = trimmed.split_whitespace().next().unwrap_or("");
590
591            if keyword.eq_ignore_ascii_case("IdentityFile") {
592                let value = trimmed[keyword.len()..].trim();
593                let value = value.trim_matches('"').trim_matches('\'');
594                return value != key_pattern_tilde && value != key_pattern_abs && value != key_name;
595            }
596
597            if keyword.eq_ignore_ascii_case("CertificateFile") {
598                let value = trimmed[keyword.len()..].trim();
599                let value = value.trim_matches('"').trim_matches('\'');
600                return value != cert_pattern_tilde
601                    && value != cert_pattern_abs
602                    && value != cert_name;
603            }
604
605            true
606        })
607        .collect::<Vec<&str>>()
608        .join(line_ending);
609
610    if trailing_newline && !new_content.is_empty() {
611        format!("{new_content}{line_ending}")
612    } else {
613        new_content
614    }
615}
616
617async fn remove_from_config(paths: &SshPaths, key_name: &str) -> Result<()> {
618    let config_path = paths.config_path().to_path_buf();
619
620    if !config_path.exists() {
621        return Ok(());
622    }
623
624    let key_name_owned = key_name.to_owned();
625    let ssh_dir_str = paths
626        .ssh_dir()
627        .to_str()
628        .ok_or_else(|| {
629            Error::Io(std::io::Error::new(
630                std::io::ErrorKind::InvalidData,
631                format!(
632                    "SSH directory path is not valid UTF-8: {}",
633                    paths.ssh_dir().display()
634                ),
635            ))
636        })?
637        .to_owned();
638
639    tokio::task::spawn_blocking(move || {
640        let content = match std::fs::read_to_string(&config_path) {
641            Ok(c) => c,
642            Err(e) => {
643                tracing::warn!("cannot read config for cleanup: {e}");
644                return Ok(());
645            }
646        };
647
648        let final_content = filter_config_lines(&content, &ssh_dir_str, &key_name_owned);
649
650        if final_content != content {
651            let parent = config_path
652                .parent()
653                .unwrap_or_else(|| std::path::Path::new("."));
654            let tmp_path = parent.join(format!(
655                ".config.tmp.{}.{}",
656                std::process::id(),
657                std::time::SystemTime::now()
658                    .duration_since(std::time::UNIX_EPOCH)
659                    .unwrap_or_default()
660                    .as_nanos()
661            ));
662            // `create_new` (O_EXCL) is load-bearing: the PID+nanos temp name is
663            // predictable, and without O_EXCL a pre-placed symlink there would redirect
664            // this write (open(2), O_EXCL symlink protection).
665            #[cfg(unix)]
666            {
667                use std::io::Write;
668                use std::os::unix::fs::OpenOptionsExt;
669                let mut file = std::fs::OpenOptions::new()
670                    .write(true)
671                    .create_new(true)
672                    .mode(0o600)
673                    .open(&tmp_path)
674                    .map_err(|e| {
675                        Error::ConfigWriteFailed(format!(
676                            "failed to create temp config {}: {e}",
677                            tmp_path.display()
678                        ))
679                    })?;
680                file.write_all(final_content.as_bytes()).map_err(|e| {
681                    Error::ConfigWriteFailed(format!("failed to write temp config: {e}"))
682                })?;
683                let _ = file.sync_all();
684                drop(file);
685            }
686            #[cfg(not(unix))]
687            {
688                use std::io::Write;
689                let mut file = std::fs::OpenOptions::new()
690                    .write(true)
691                    .create_new(true)
692                    .open(&tmp_path)
693                    .map_err(|e| {
694                        Error::ConfigWriteFailed(format!(
695                            "failed to create temp config {}: {e}",
696                            tmp_path.display()
697                        ))
698                    })?;
699                file.write_all(final_content.as_bytes()).map_err(|e| {
700                    Error::ConfigWriteFailed(format!("failed to write temp config: {e}"))
701                })?;
702                let _ = file.sync_all();
703                drop(file);
704            }
705            if let Err(e) = std::fs::rename(&tmp_path, &config_path) {
706                let _ = std::fs::remove_file(&tmp_path);
707                return Err(Error::ConfigWriteFailed(format!(
708                    "failed to rename config: {e}"
709                )));
710            }
711        }
712
713        Ok(())
714    })
715    .await
716    .map_err(|e| Error::TaskFailed(format!("config cleanup task failed: {e}")))?
717}
718
719#[cfg(test)]
720#[path = "mod.test.rs"]
721mod tests;