aboutsummaryrefslogtreecommitdiff
path: root/src/platform/windows/config_backup.rs
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-14 23:13:52 +0300
committerGitHub <noreply@github.com>2026-09-14 22:13:52 +0200
commit5e38e5fb5fc2cc5d30844df71432a60742bcb572 (patch)
tree31896855b510cf03c57e19e4e3ef454bef028a59 /src/platform/windows/config_backup.rs
parentb0f21ed2cb85613050b794bf6c78a7c367a94e0d (diff)
fix: protect integration configs from failed writes (#3973)
* fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 * fix: atomically write integration configs refs #3970 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/platform/windows/config_backup.rs')
-rw-r--r--src/platform/windows/config_backup.rs165
1 files changed, 165 insertions, 0 deletions
diff --git a/src/platform/windows/config_backup.rs b/src/platform/windows/config_backup.rs
new file mode 100644
index 00000000..8e7390ab
--- /dev/null
+++ b/src/platform/windows/config_backup.rs
@@ -0,0 +1,165 @@
+//! Recovery-backed writes for existing Windows integration configs.
+
+use std::fs::{self, File, OpenOptions};
+use std::io::{self, Read, Seek, Write};
+use std::os::windows::{fs::MetadataExt, io::AsRawHandle};
+use std::path::{Path, PathBuf};
+use windows_sys::Win32::Storage::FileSystem::{
+ GetFileInformationByHandle, MoveFileExW, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_ENCRYPTED,
+};
+
+#[cfg(test)]
+mod tests;
+
+fn backup_paths(target: &Path) -> (PathBuf, PathBuf) {
+ let mut name = target.as_os_str().to_os_string();
+ name.push(".herdr-backup");
+ let complete = PathBuf::from(&name);
+ name.push(".pending");
+ (complete, PathBuf::from(name))
+}
+
+pub(super) fn check_recovery(target: &Path) -> io::Result<()> {
+ let (complete, pending) = backup_paths(target);
+ for (path, description) in [
+ (&complete, "recovery copy"),
+ (&pending, "unfinished backup"),
+ ] {
+ match fs::symlink_metadata(path) {
+ Ok(_) => {
+ return Err(io::Error::other(format!(
+ "cannot update {}: {description} exists at {}; inspect the config and resolve this backup before retrying",
+ target.display(), path.display()
+ )));
+ }
+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
+ Err(error) => return Err(error),
+ }
+ }
+ Ok(())
+}
+
+struct PendingBackup(PathBuf);
+impl Drop for PendingBackup {
+ fn drop(&mut self) {
+ // Only the incomplete name is owned by this cleanup. The completed
+ // recovery copy must survive errors and must never be removed here.
+ if let Err(error) = fs::remove_file(&self.0) {
+ if error.kind() != io::ErrorKind::NotFound {
+ tracing::warn!(path = %self.0.display(), %error, "failed to remove incomplete config backup");
+ }
+ }
+ }
+}
+
+// Internal observation points let native tests terminate a child at actual I/O
+// boundaries. The production callback is a no-op; there is no runtime failpoint.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum Phase {
+ BackupChunk,
+ BackupReady,
+ Truncated,
+ Committed,
+}
+
+pub(super) fn write_existing(target: &Path, contents: &[u8]) -> io::Result<bool> {
+ write_observed(target, contents, |_| Ok(()))
+}
+
+fn check_source(file: &File, target: &Path) -> io::Result<()> {
+ if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_ENCRYPTED != 0 {
+ return Err(io::Error::other(format!(
+ "cannot update {}: encrypted configs are not supported by recovery-backed writes; the original was not changed",
+ target.display()
+ )));
+ }
+ let mut info = BY_HANDLE_FILE_INFORMATION::default();
+ if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ if info.nNumberOfLinks > 1 {
+ return Err(io::Error::other(format!(
+ "cannot update {}: config has multiple hard links; use a separate file or a symlink before retrying",
+ target.display()
+ )));
+ }
+ Ok(())
+}
+
+fn write_observed(
+ target: &Path,
+ contents: &[u8],
+ mut observe: impl FnMut(Phase) -> io::Result<()>,
+) -> io::Result<bool> {
+ check_recovery(target)?;
+ // Keep this handle for both backup reads and the destructive write. Do not
+ // reopen the pathname after saving the original contents.
+ let mut source = match OpenOptions::new().read(true).write(true).open(target) {
+ Ok(source) => source,
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
+ Err(error) => return Err(error),
+ };
+ check_source(&source, target)?;
+ let (complete, pending) = backup_paths(target);
+ let mut backup = super::create_config_temporary(&pending, true)?;
+ let pending = PendingBackup(pending);
+ let preparation = (|| {
+ let mut remaining = source.metadata()?.len();
+ let mut buffer = [0_u8; 8192];
+ while remaining != 0 {
+ let requested = remaining.min(buffer.len() as u64) as usize;
+ let count = source.read(&mut buffer[..requested])?;
+ if count == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::UnexpectedEof,
+ "config shortened during backup",
+ ));
+ }
+ backup.write_all(&buffer[..count])?;
+ remaining -= count as u64;
+ observe(Phase::BackupChunk)?;
+ }
+ backup.sync_all()?;
+ drop(backup);
+ let from = super::extended_length_path(&pending.0)?;
+ let to = super::extended_length_path(&complete)?;
+ // No REPLACE_EXISTING: never overwrite a previous recovery copy.
+ if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), 0) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok::<_, io::Error>(())
+ })();
+ preparation.map_err(|error| {
+ io::Error::other(format!(
+ "cannot back up {}; original not changed (unfinished backup: {}): {error}",
+ target.display(),
+ pending.0.display()
+ ))
+ })?;
+
+ let update = (|| {
+ observe(Phase::BackupReady)?;
+ check_source(&source, target)?;
+ source.rewind()?;
+ source.set_len(0)?;
+ observe(Phase::Truncated)?;
+ source.write_all(contents)?;
+ source.sync_all()?;
+ Ok::<_, io::Error>(())
+ })();
+ update.map_err(|error| {
+ io::Error::other(format!(
+ "could not update {}; config may be incomplete; original contents retained at {}: {error}",
+ target.display(), complete.display()
+ ))
+ })?;
+ // Success here is after the new contents were synced. A cleanup failure is
+ // not an unchanged-original failure, and must not trigger automatic rollback.
+ observe(Phase::Committed).and_then(|()| fs::remove_file(&complete)).map_err(|error| {
+ io::Error::other(format!(
+ "updated {}, but recovery copy remains at {}; verify the config and remove that copy before retrying: {error}",
+ target.display(), complete.display()
+ ))
+ })?;
+ Ok(true)
+}