aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/linux.rs131
-rw-r--r--src/platform/linux/config_file_tests.rs96
-rw-r--r--src/platform/macos.rs143
-rw-r--r--src/platform/macos/config_file_tests.rs44
-rw-r--r--src/platform/windows.rs154
-rw-r--r--src/platform/windows/config_backup.rs165
-rw-r--r--src/platform/windows/config_backup/tests.rs375
7 files changed, 1108 insertions, 0 deletions
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 7ea0a2c6..7dd2879e 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -23,6 +23,9 @@ pub(crate) use super::unix_common::{
ClientStreamReader, StatusCommandGuard,
};
+#[cfg(test)]
+mod config_file_tests;
+
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION";
const CHILD_GROUPS_SCAN_LIMIT: usize = 64;
@@ -104,6 +107,134 @@ pub(crate) fn launch_executable() -> std::io::Result<PathBuf> {
Ok(executable)
}
+pub(crate) fn config_file_link_count(path: &std::path::Path) -> std::io::Result<u64> {
+ use std::os::unix::fs::MetadataExt;
+ Ok(std::fs::metadata(path)?.nlink())
+}
+
+pub(crate) fn check_config_write_target(_target: &std::path::Path) -> std::io::Result<()> {
+ Ok(())
+}
+
+pub(crate) fn write_existing_config(
+ _target: &std::path::Path,
+ _contents: &[u8],
+) -> std::io::Result<bool> {
+ // Unix keeps atomic replacement for existing files too.
+ Ok(false)
+}
+
+pub(crate) fn create_config_temporary(
+ path: &std::path::Path,
+ private: bool,
+) -> std::io::Result<std::fs::File> {
+ use std::os::unix::fs::OpenOptionsExt;
+ std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(if private { 0o600 } else { 0o666 })
+ .open(path)
+}
+
+pub(crate) fn write_config_temporary(
+ source: Option<&std::path::Path>,
+ temporary: &std::path::Path,
+ contents: &[u8],
+) -> std::io::Result<()> {
+ use std::os::{fd::AsRawFd, unix::fs::MetadataExt};
+ let mut output = std::fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(temporary)?;
+ if let Some(source) = source {
+ let input = std::fs::File::open(source)?;
+ let metadata = input.metadata()?;
+ let current = output.metadata()?;
+ if (metadata.uid(), metadata.gid()) != (current.uid(), current.gid()) {
+ // Keep ownership before restoring mode/ACLs; chown can clear mode bits.
+ if unsafe { libc::fchown(output.as_raw_fd(), metadata.uid(), metadata.gid()) } != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ }
+ // Replace inherited ACLs before enabling the original mode. Prepare all
+ // access controls while the temporary is empty, before writing secrets.
+ copy_config_xattrs(input.as_raw_fd(), output.as_raw_fd())?;
+ output.set_permissions(metadata.permissions())?;
+ }
+ output.write_all(contents)?;
+ output.sync_all()
+}
+
+// Access ACLs and security labels live in xattrs on Linux. Mode bits alone can
+// silently broaden access, especially with a default ACL on the parent directory.
+fn copy_config_xattrs(source: RawFd, destination: RawFd) -> std::io::Result<()> {
+ use std::ffi::CStr;
+ fn names(fd: RawFd) -> std::io::Result<Vec<u8>> {
+ let size = unsafe { libc::flistxattr(fd, std::ptr::null_mut(), 0) };
+ if size < 0 {
+ let error = std::io::Error::last_os_error();
+ if error.raw_os_error() == Some(libc::ENOTSUP) {
+ return Ok(Vec::new());
+ }
+ return Err(error);
+ }
+ let mut buffer = vec![0; size as usize];
+ let read = unsafe { libc::flistxattr(fd, buffer.as_mut_ptr().cast(), buffer.len()) };
+ if read < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ buffer.truncate(read as usize);
+ Ok(buffer)
+ }
+ fn value(fd: RawFd, name: &CStr) -> std::io::Result<Vec<u8>> {
+ let size = unsafe { libc::fgetxattr(fd, name.as_ptr(), std::ptr::null_mut(), 0) };
+ if size < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let mut buffer = vec![0; size as usize];
+ let read =
+ unsafe { libc::fgetxattr(fd, name.as_ptr(), buffer.as_mut_ptr().cast(), buffer.len()) };
+ if read < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ buffer.truncate(read as usize);
+ Ok(buffer)
+ }
+ let source_names = names(source)?;
+ for bytes in names(destination)?.split_inclusive(|byte| *byte == 0) {
+ if !source_names
+ .split_inclusive(|byte| *byte == 0)
+ .any(|name| name == bytes)
+ {
+ let name = CStr::from_bytes_with_nul(bytes).map_err(std::io::Error::other)?;
+ if unsafe { libc::fremovexattr(destination, name.as_ptr()) } != 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ }
+ }
+ for bytes in source_names.split_inclusive(|byte| *byte == 0) {
+ let name = CStr::from_bytes_with_nul(bytes).map_err(std::io::Error::other)?;
+ let original = value(source, name)?;
+ // Avoid requiring relabel privileges when the inherited label already matches.
+ if value(destination, name).is_ok_and(|current| current == original) {
+ continue;
+ }
+ if unsafe {
+ libc::fsetxattr(
+ destination,
+ name.as_ptr(),
+ original.as_ptr().cast(),
+ original.len(),
+ 0,
+ )
+ } != 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ }
+ Ok(())
+}
+
pub fn raise_server_nofile_limit() {}
pub(crate) fn should_draw_host_cursor_by_default() -> bool {
diff --git a/src/platform/linux/config_file_tests.rs b/src/platform/linux/config_file_tests.rs
new file mode 100644
index 00000000..b11c117d
--- /dev/null
+++ b/src/platform/linux/config_file_tests.rs
@@ -0,0 +1,96 @@
+use super::*;
+use std::ffi::CStr;
+use std::os::{fd::AsRawFd, unix::fs::MetadataExt};
+
+fn set_attribute(file: &std::fs::File, name: &CStr, value: &[u8]) {
+ assert_eq!(
+ unsafe {
+ libc::fsetxattr(
+ file.as_raw_fd(),
+ name.as_ptr(),
+ value.as_ptr().cast(),
+ value.len(),
+ 0,
+ )
+ },
+ 0,
+ "{}",
+ std::io::Error::last_os_error()
+ );
+}
+
+fn attribute(file: &std::fs::File, name: &CStr) -> Option<Vec<u8>> {
+ let mut value = vec![0; 1024];
+ let read = unsafe {
+ libc::fgetxattr(
+ file.as_raw_fd(),
+ name.as_ptr(),
+ value.as_mut_ptr().cast(),
+ value.len(),
+ )
+ };
+ if read < 0 {
+ assert_eq!(
+ std::io::Error::last_os_error().raw_os_error(),
+ Some(libc::ENODATA)
+ );
+ return None;
+ }
+ value.truncate(read as usize);
+ Some(value)
+}
+
+#[test]
+fn config_metadata_preserves_ownership_and_acl_without_inheriting_extra_access() {
+ let dir = std::env::temp_dir().join(format!("herdr-config-acl-{}", std::process::id()));
+ std::fs::create_dir(&dir).unwrap();
+ // Linux UAPI posix_acl_xattr_header/entry, version 2, little-endian fields.
+ // Owner rw, named user 65534 read, group none, mask read, other none.
+ let mut acl = 2_u32.to_le_bytes().to_vec();
+ for (tag, permissions, id) in [
+ (1_u16, 6_u16, u32::MAX),
+ (2, 4, 65534),
+ (4, 0, u32::MAX),
+ (16, 4, u32::MAX),
+ (32, 0, u32::MAX),
+ ] {
+ acl.extend(tag.to_le_bytes());
+ acl.extend(permissions.to_le_bytes());
+ acl.extend(id.to_le_bytes());
+ }
+ for has_acl in [false, true] {
+ let source = dir.join(format!("source-{has_acl}"));
+ let target = dir.join(format!("target-{has_acl}"));
+ std::fs::write(&source, b"old").unwrap();
+ let input = std::fs::File::open(&source).unwrap();
+ if unsafe { libc::geteuid() } == 0 {
+ assert_eq!(unsafe { libc::fchown(input.as_raw_fd(), 1001, 1002) }, 0);
+ }
+ if has_acl {
+ set_attribute(&input, c"system.posix_acl_access", &acl);
+ }
+ set_attribute(&input, c"user.herdr-test", b"preserve this attribute");
+ let original = input.metadata().unwrap();
+ drop(create_config_temporary(&target, true).unwrap());
+ let output = std::fs::File::open(&target).unwrap();
+ // Model a default ACL inherited from the destination's parent directory.
+ set_attribute(&output, c"system.posix_acl_access", &acl);
+ write_config_temporary(Some(&source), &target, b"new").unwrap();
+ let actual = output.metadata().unwrap();
+ assert_eq!(
+ (actual.uid(), actual.gid(), actual.mode()),
+ (original.uid(), original.gid(), original.mode())
+ );
+ assert_eq!(
+ attribute(&output, c"system.posix_acl_access"),
+ attribute(&input, c"system.posix_acl_access")
+ );
+ assert_eq!(
+ attribute(&output, c"user.herdr-test"),
+ Some(b"preserve this attribute".to_vec())
+ );
+ assert_eq!(std::fs::read(source).unwrap(), b"old");
+ assert_eq!(std::fs::read(target).unwrap(), b"new");
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index 4e86deab..4b11a618 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -23,6 +23,149 @@ pub(crate) use super::unix_common::{
ClientStreamReader, StatusCommandGuard,
};
+#[cfg(test)]
+mod config_file_tests;
+
+pub(crate) fn config_file_link_count(path: &Path) -> std::io::Result<u64> {
+ use std::os::unix::fs::MetadataExt;
+ Ok(std::fs::metadata(path)?.nlink())
+}
+
+pub(crate) fn check_config_write_target(_target: &std::path::Path) -> std::io::Result<()> {
+ Ok(())
+}
+
+pub(crate) fn write_existing_config(
+ _target: &std::path::Path,
+ _contents: &[u8],
+) -> std::io::Result<bool> {
+ // Unix keeps atomic replacement for existing files too.
+ Ok(false)
+}
+
+pub(crate) fn create_config_temporary(
+ path: &Path,
+ private: bool,
+) -> std::io::Result<std::fs::File> {
+ if !private {
+ return std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path);
+ }
+ use std::os::fd::FromRawFd;
+ // Darwin's opaque ACL/filesec APIs (<sys/acl.h>, <sys/fcntl.h>) are not
+ // exposed by libc. Supply a non-inheriting empty ACL at creation: clearing
+ // inherited ACEs later cannot revoke descriptors opened in the meantime.
+ unsafe extern "C" {
+ fn acl_init(count: libc::c_int) -> *mut libc::c_void;
+ fn acl_free(acl: *mut libc::c_void) -> libc::c_int;
+ fn acl_get_flagset_np(acl: *mut libc::c_void, flags: *mut *mut libc::c_void)
+ -> libc::c_int;
+ fn acl_add_flag_np(flags: *mut libc::c_void, flag: libc::c_uint) -> libc::c_int;
+ fn filesec_init() -> *mut libc::c_void;
+ fn filesec_free(security: *mut libc::c_void);
+ fn filesec_set_property(
+ security: *mut libc::c_void,
+ property: libc::c_int,
+ value: *const libc::c_void,
+ ) -> libc::c_int;
+ fn openx_np(
+ path: *const libc::c_char,
+ flags: libc::c_int,
+ security: *mut libc::c_void,
+ ) -> libc::c_int;
+ }
+ const FILESEC_MODE: libc::c_int = 4;
+ const FILESEC_ACL: libc::c_int = 5;
+ const ACL_FLAG_NO_INHERIT: libc::c_uint = 1 << 17;
+ let path =
+ std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
+ let acl = unsafe { acl_init(0) };
+ if acl.is_null() {
+ return Err(std::io::Error::last_os_error());
+ }
+ let security = unsafe { filesec_init() };
+ if security.is_null() {
+ let error = std::io::Error::last_os_error();
+ unsafe {
+ acl_free(acl);
+ }
+ return Err(error);
+ }
+ let result = (|| {
+ let mut flags = std::ptr::null_mut();
+ let mode: libc::mode_t = 0o600;
+ if unsafe { acl_get_flagset_np(acl, &mut flags) } != 0
+ || unsafe { acl_add_flag_np(flags, ACL_FLAG_NO_INHERIT) } != 0
+ || unsafe {
+ filesec_set_property(security, FILESEC_MODE, std::ptr::from_ref(&mode).cast())
+ } != 0
+ || unsafe {
+ filesec_set_property(security, FILESEC_ACL, std::ptr::from_ref(&acl).cast())
+ } != 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ let fd = unsafe {
+ openx_np(
+ path.as_ptr(),
+ libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
+ security,
+ )
+ };
+ if fd < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ // The successful exclusive create returns one owned descriptor.
+ Ok(unsafe { std::fs::File::from_raw_fd(fd) })
+ })();
+ unsafe {
+ filesec_free(security);
+ acl_free(acl);
+ }
+ result
+}
+
+pub(crate) fn write_config_temporary(
+ source: Option<&Path>,
+ temporary: &Path,
+ contents: &[u8],
+) -> std::io::Result<()> {
+ use std::os::{fd::AsRawFd, unix::fs::MetadataExt};
+ let mut output = std::fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(temporary)?;
+ if let Some(source) = source {
+ let input = std::fs::File::open(source)?;
+ let metadata = input.metadata()?;
+ let current = output.metadata()?;
+ if (metadata.uid(), metadata.gid()) != (current.uid(), current.gid())
+ && unsafe { libc::fchown(output.as_raw_fd(), metadata.uid(), metadata.gid()) } != 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ // Prepare access controls while the temporary is still empty. Copy ACLs
+ // before mode bits so no inherited/default grant can expose the content.
+ // Do not copy data or old timestamps.
+ if unsafe {
+ libc::fcopyfile(
+ input.as_raw_fd(),
+ output.as_raw_fd(),
+ std::ptr::null_mut(),
+ libc::COPYFILE_ACL | libc::COPYFILE_XATTR,
+ )
+ } != 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ output.set_permissions(metadata.permissions())?;
+ }
+ output.write_all(contents)?;
+ output.sync_all()
+}
+
const PROC_PGRP_ONLY: u32 = 2;
const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192;
diff --git a/src/platform/macos/config_file_tests.rs b/src/platform/macos/config_file_tests.rs
new file mode 100644
index 00000000..f08d6c2a
--- /dev/null
+++ b/src/platform/macos/config_file_tests.rs
@@ -0,0 +1,44 @@
+use super::*;
+
+#[test]
+fn config_replacement_preserves_macos_acl() {
+ let dir = std::env::temp_dir().join(format!("herdr-config-acl-{}", std::process::id()));
+ std::fs::create_dir(&dir).unwrap();
+ let source = dir.join("source");
+ let temporary = dir.join("temporary");
+ std::fs::write(&source, b"original").unwrap();
+ assert!(Command::new("chmod")
+ .args(["+a", "everyone deny execute"])
+ .arg(&source)
+ .status()
+ .unwrap()
+ .success());
+ let acl = |path: &Path| {
+ let output = Command::new("ls").arg("-le").arg(path).output().unwrap();
+ assert!(output.status.success());
+ String::from_utf8(output.stdout)
+ .unwrap()
+ .lines()
+ .skip(1)
+ .collect::<Vec<_>>()
+ .join("\n")
+ };
+ let original = acl(&source);
+ assert!(original.contains("everyone deny execute"));
+ assert!(Command::new("chmod")
+ .args(["+a", "everyone allow read,file_inherit"])
+ .arg(&dir)
+ .status()
+ .unwrap()
+ .success());
+ drop(create_config_temporary(&temporary, true).unwrap());
+ assert!(
+ acl(&temporary).is_empty(),
+ "staging must not inherit allow ACEs"
+ );
+ write_config_temporary(Some(&source), &temporary, b"new").unwrap();
+ assert_eq!(acl(&temporary), original);
+ assert_eq!(std::fs::read(&source).unwrap(), b"original");
+ assert_eq!(std::fs::read(&temporary).unwrap(), b"new");
+ std::fs::remove_dir_all(dir).unwrap();
+}
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index 879778cc..080bbf34 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -14,6 +14,7 @@ use std::{
};
mod clipboard_image;
+mod config_backup;
pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason {
// STATUS_CONTROL_C_EXIT is reported without a Unix signal by portable-pty.
@@ -140,6 +141,159 @@ pub(crate) fn replace_file(
}
}
+pub(crate) fn config_file_link_count(path: &std::path::Path) -> std::io::Result<u64> {
+ use windows_sys::Win32::Storage::FileSystem::{
+ GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
+ };
+ let file = std::fs::File::open(path)?;
+ let mut info = BY_HANDLE_FILE_INFORMATION::default();
+ if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(u64::from(info.nNumberOfLinks))
+}
+
+pub(crate) fn create_config_temporary(
+ path: &std::path::Path,
+ private: bool,
+) -> std::io::Result<std::fs::File> {
+ if !private {
+ return std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path);
+ }
+ use interprocess::os::windows::security_descriptor::{
+ AsSecurityDescriptorExt as _, SecurityDescriptor,
+ };
+ use widestring::U16CString;
+ use windows_sys::Win32::{
+ Foundation::GENERIC_WRITE,
+ Storage::FileSystem::{
+ CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ,
+ FILE_SHARE_WRITE,
+ },
+ };
+ let sddl =
+ U16CString::from_str("D:P(A;;GA;;;SY)(A;;GA;;;OW)").map_err(std::io::Error::other)?;
+ let descriptor = SecurityDescriptor::deserialize(&sddl)?;
+ let mut attributes = SECURITY_ATTRIBUTES {
+ nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
+ lpSecurityDescriptor: null_mut(),
+ bInheritHandle: 0,
+ };
+ descriptor.write_to_security_attributes(&mut attributes);
+ let path = extended_length_path(path)?;
+ let handle = unsafe {
+ CreateFileW(
+ path.as_ptr(),
+ GENERIC_WRITE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ &attributes,
+ CREATE_NEW,
+ FILE_ATTRIBUTE_NORMAL,
+ null_mut(),
+ )
+ };
+ if handle == INVALID_HANDLE_VALUE {
+ return Err(std::io::Error::last_os_error());
+ }
+ // CreateFileW returned an owned handle; File closes it exactly once.
+ Ok(unsafe { std::fs::File::from_raw_handle(handle) })
+}
+
+pub(crate) fn write_config_temporary(
+ source: Option<&std::path::Path>,
+ temporary: &std::path::Path,
+ contents: &[u8],
+) -> std::io::Result<()> {
+ use std::io::Write;
+ if source.is_some() {
+ // If preparation finds an existing file, leave it to the recovery-backed
+ // path instead of applying replacement-file permissions.
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ "config appeared while preparing a new file; retry the update",
+ ));
+ }
+ let mut output = std::fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(temporary)?;
+ output.write_all(contents)?;
+ output.sync_all()
+}
+
+pub(crate) fn check_config_write_target(target: &std::path::Path) -> std::io::Result<()> {
+ config_backup::check_recovery(target)
+}
+
+pub(crate) fn write_existing_config(
+ target: &std::path::Path,
+ contents: &[u8],
+) -> std::io::Result<bool> {
+ config_backup::write_existing(target, contents)
+}
+
+#[cfg(test)]
+fn config_security_descriptor(
+ path: &std::path::Path,
+ information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION,
+) -> std::io::Result<Vec<u8>> {
+ use windows_sys::Win32::Security::GetFileSecurityW;
+ let path = extended_length_path(path)?;
+ let mut needed = 0;
+ unsafe { GetFileSecurityW(path.as_ptr(), information, null_mut(), 0, &mut needed) };
+ if needed == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ let mut descriptor = vec![0_u8; needed as usize];
+ if unsafe {
+ GetFileSecurityW(
+ path.as_ptr(),
+ information,
+ descriptor.as_mut_ptr().cast(),
+ needed,
+ &mut needed,
+ )
+ } == 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(descriptor)
+}
+
+#[cfg(test)]
+fn config_security_sddl(
+ descriptor: &mut [u8],
+ information: windows_sys::Win32::Security::OBJECT_SECURITY_INFORMATION,
+) -> std::io::Result<Vec<u16>> {
+ use windows_sys::Win32::Security::{
+ Authorization::{ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1},
+ SACL_SECURITY_INFORMATION,
+ };
+ let mut text = null_mut();
+ if unsafe {
+ ConvertSecurityDescriptorToStringSecurityDescriptorW(
+ descriptor.as_mut_ptr().cast(),
+ SDDL_REVISION_1,
+ // Only labels were queried from the SACL. Serialize that returned
+ // SACL too; this does not request audit access to either file.
+ information | SACL_SECURITY_INFORMATION,
+ &mut text,
+ null_mut(),
+ )
+ } == 0
+ {
+ return Err(std::io::Error::last_os_error());
+ }
+ let result = unsafe { widestring::U16CStr::from_ptr_str(text) }
+ .as_slice()
+ .to_vec();
+ unsafe { LocalFree(text.cast()) };
+ Ok(result)
+}
+
pub(crate) fn set_default_plugin_pane_pwd(
_env: &mut Vec<(String, String)>,
_cwd: &std::path::Path,
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)
+}
diff --git a/src/platform/windows/config_backup/tests.rs b/src/platform/windows/config_backup/tests.rs
new file mode 100644
index 00000000..966ba2a9
--- /dev/null
+++ b/src/platform/windows/config_backup/tests.rs
@@ -0,0 +1,375 @@
+use super::super::{config_security_descriptor, config_security_sddl};
+use super::*;
+use std::process::Command;
+use windows_sys::Win32::{
+ Security::{
+ DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, LABEL_SECURITY_INFORMATION,
+ OWNER_SECURITY_INFORMATION,
+ },
+ Storage::FileSystem::{
+ EncryptFileW, LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
+ },
+ System::IO::OVERLAPPED,
+};
+
+struct Directory(PathBuf);
+impl Directory {
+ fn new(case: &str) -> Self {
+ let path =
+ std::env::temp_dir().join(format!("herdr-config-backup-{case}-{}", std::process::id()));
+ fs::create_dir(&path).unwrap();
+ Self(path)
+ }
+}
+impl Drop for Directory {
+ fn drop(&mut self) {
+ let _ = fs::remove_dir_all(&self.0);
+ }
+}
+fn powershell(script: &str, path: &Path) {
+ let output = Command::new("powershell.exe")
+ .args(["-NoProfile", "-NonInteractive", "-Command", script])
+ .env("HERDR_TEST_CONFIG_SOURCE", path)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success() && output.stderr.is_empty(),
+ "{output:?}"
+ );
+}
+fn security(path: &Path) -> Vec<u16> {
+ let flags = OWNER_SECURITY_INFORMATION
+ | GROUP_SECURITY_INFORMATION
+ | DACL_SECURITY_INFORMATION
+ | LABEL_SECURITY_INFORMATION;
+ let mut descriptor = config_security_descriptor(path, flags).unwrap();
+ config_security_sddl(&mut descriptor, flags).unwrap()
+}
+fn identity(path: &Path) -> (u32, u32, u32) {
+ let file = File::open(path).unwrap();
+ let mut info = BY_HANDLE_FILE_INFORMATION::default();
+ assert_ne!(
+ unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) },
+ 0
+ );
+ (
+ info.dwVolumeSerialNumber,
+ info.nFileIndexHigh,
+ info.nFileIndexLow,
+ )
+}
+fn lock_range(path: &Path, offset: u32) -> File {
+ let file = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .open(path)
+ .unwrap();
+ let mut overlapped = OVERLAPPED::default();
+ overlapped.Anonymous.Anonymous.Offset = offset;
+ assert_ne!(
+ unsafe {
+ LockFileEx(
+ file.as_raw_handle(),
+ LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
+ 0,
+ 1,
+ 0,
+ &mut overlapped,
+ )
+ },
+ 0,
+ "{}",
+ io::Error::last_os_error()
+ );
+ file
+}
+fn successful_update(case: &str) {
+ let dir = Directory::new(case);
+ let source = dir.0.join("config");
+ fs::write(&source, b"original preferences").unwrap();
+ fs::write(dir.0.join("config:private"), b"original stream").unwrap();
+ if case == "protected" || case == "unprotected" || case == "moved" {
+ powershell(
+ &format!(
+ r#"
+$ErrorActionPreference = 'Stop'
+$acl = [System.IO.File]::GetAccessControl($env:HERDR_TEST_CONFIG_SOURCE)
+$acl.SetAccessRuleProtection(${}, $false)
+$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
+$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow)
+$acl.AddAccessRule($rule)
+[System.IO.File]::SetAccessControl($env:HERDR_TEST_CONFIG_SOURCE, $acl)
+"#,
+ case == "protected"
+ ),
+ &source,
+ );
+ }
+ if case == "metadata" {
+ powershell(
+ r#"
+$ErrorActionPreference = 'Stop'
+$acl = [System.IO.File]::GetAccessControl($env:HERDR_TEST_CONFIG_SOURCE)
+$acl.SetOwner([System.Security.Principal.WindowsIdentity]::GetCurrent().User)
+$acl.SetGroup([System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'))
+[System.IO.File]::SetAccessControl($env:HERDR_TEST_CONFIG_SOURCE, $acl)
+$null = & icacls.exe $env:HERDR_TEST_CONFIG_SOURCE /setintegritylevel L
+if ($LASTEXITCODE -ne 0) { throw 'could not install low integrity label' }
+"#,
+ &source,
+ );
+ }
+ let source = if case == "moved" {
+ let original = security(&source);
+ let parent = dir.0.join("broader-parent");
+ fs::create_dir(&parent).unwrap();
+ powershell(
+ r#"
+$ErrorActionPreference = 'Stop'
+$acl = [System.IO.Directory]::GetAccessControl($env:HERDR_TEST_CONFIG_SOURCE)
+$sid = [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-546')
+$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::Read, [System.Security.AccessControl.InheritanceFlags]::ObjectInherit, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)
+$acl.AddAccessRule($rule)
+[System.IO.Directory]::SetAccessControl($env:HERDR_TEST_CONFIG_SOURCE, $acl)
+"#,
+ &parent,
+ );
+ let moved = parent.join("config");
+ fs::rename(&source, &moved).unwrap();
+ assert_eq!(security(&moved), original);
+ assert!(!String::from_utf16_lossy(&original).contains(";;;BG)"));
+ let inherited = parent.join("inherited");
+ fs::write(&inherited, b"").unwrap();
+ assert!(String::from_utf16_lossy(&security(&inherited)).contains(";;;BG)"));
+ moved
+ } else {
+ source
+ };
+ let before = security(&source);
+ let before_id = identity(&source);
+ let text = String::from_utf16_lossy(&before);
+ match case {
+ "legacy" => assert!(text.contains("D:("), "{text}"),
+ "protected" => assert!(text.contains("D:PAI"), "{text}"),
+ "unprotected" | "moved" => assert!(text.contains("D:AI"), "{text}"),
+ "metadata" => assert!(text.contains("G:BU") && text.contains(";;;LW)"), "{text}"),
+ _ => unreachable!(),
+ }
+ let (complete, pending) = backup_paths(&source);
+ let mut phases = Vec::new();
+ assert!(write_observed(&source, b"new", |phase| {
+ phases.push(phase);
+ if phase == Phase::BackupReady {
+ assert_eq!(fs::read(&complete).unwrap(), b"original preferences");
+ assert_eq!(fs::read(&source).unwrap(), b"original preferences");
+ assert!(!pending.exists());
+ let private = String::from_utf16_lossy(&security(&complete));
+ assert!(
+ private.contains("D:P") && !private.contains(";;;BG)"),
+ "{private}"
+ );
+ }
+ if phase == Phase::Truncated {
+ assert!(fs::read(&source).unwrap().is_empty());
+ }
+ Ok(())
+ })
+ .unwrap());
+ assert_eq!(
+ phases,
+ [
+ Phase::BackupChunk,
+ Phase::BackupReady,
+ Phase::Truncated,
+ Phase::Committed
+ ]
+ );
+ assert_eq!(fs::read(&source).unwrap(), b"new");
+ assert_eq!(
+ fs::read(source.with_file_name("config:private")).unwrap(),
+ b"original stream"
+ );
+ assert_eq!(security(&source), before);
+ assert_eq!(identity(&source), before_id);
+ assert!(!complete.exists() && !pending.exists());
+}
+#[test]
+fn backup_preserves_legacy() {
+ successful_update("legacy");
+}
+#[test]
+fn backup_preserves_protected() {
+ successful_update("protected");
+}
+#[test]
+fn backup_preserves_unprotected() {
+ successful_update("unprotected");
+}
+#[test]
+fn backup_preserves_moved() {
+ successful_update("moved");
+}
+#[test]
+fn backup_preserves_metadata() {
+ successful_update("metadata");
+}
+
+#[test]
+fn native_io_failures_preserve_recovery_ordering() {
+ let dir = Directory::new("io-failures");
+ for during_backup in [true, false] {
+ let path = dir.0.join(if during_backup { "backup" } else { "write" });
+ let original = vec![b'a'; if during_backup { 32768 } else { 16 }];
+ fs::write(&path, &original).unwrap();
+ let before = security(&path);
+ let before_id = identity(&path);
+ // First case fails a later backup read after one real chunk. In the
+ // second, lock after truncation so the actual new WriteFile must fail.
+ let mut locked = during_backup.then(|| lock_range(&path, 8192));
+ let mut phases = Vec::new();
+ let error = write_observed(&path, &vec![b'x'; 16384], |phase| {
+ phases.push(phase);
+ if !during_backup && phase == Phase::Truncated {
+ locked = Some(lock_range(&path, 4096));
+ }
+ Ok(())
+ })
+ .unwrap_err();
+ eprintln!("during_backup={during_backup}: {error}; phases={phases:?}");
+ assert!(error.to_string().contains("os error 33"));
+ drop(locked);
+ let (complete, pending) = backup_paths(&path);
+ assert!(!pending.exists());
+ assert_eq!(security(&path), before);
+ assert_eq!(identity(&path), before_id);
+ if during_backup {
+ assert_eq!(phases, [Phase::BackupChunk]);
+ assert_eq!(fs::read(&path).unwrap(), original);
+ assert!(!complete.exists());
+ } else {
+ assert!(phases.contains(&Phase::Truncated));
+ assert_eq!(fs::read(&complete).unwrap(), original);
+ assert!(error.to_string().contains(&complete.display().to_string()));
+ assert!(write_existing(&path, b"retry")
+ .unwrap_err()
+ .to_string()
+ .contains("recovery copy"));
+ // Recovery is contents-only, never rename over the original file.
+ fs::write(&path, fs::read(&complete).unwrap()).unwrap();
+ assert_eq!(identity(&path), before_id);
+ assert_eq!(security(&path), before);
+ fs::remove_file(&complete).unwrap();
+ assert!(write_existing(&path, b"resolved").unwrap());
+ }
+ }
+}
+
+#[test]
+fn interrupted_backups_and_writes_are_distinguishable() {
+ const CHILD: &str = "HERDR_BACKUP_CRASH_TEST";
+ if let Some(path) = std::env::var_os(CHILD) {
+ let path = PathBuf::from(path);
+ let crash_phase = if path.file_name().unwrap() == "copy" {
+ Phase::BackupChunk
+ } else {
+ Phase::Truncated
+ };
+ let _ = write_observed(&path, b"new", |phase| {
+ if phase == crash_phase {
+ std::process::exit(23);
+ }
+ Ok(())
+ });
+ panic!("crash phase not reached");
+ }
+ let dir = Directory::new("crash");
+ for name in ["copy", "write"] {
+ let path = dir.0.join(name);
+ let original = vec![b'a'; 32768];
+ fs::write(&path, &original).unwrap();
+ let output = Command::new(std::env::current_exe().unwrap()).args(["--exact", "platform::windows::config_backup::tests::interrupted_backups_and_writes_are_distinguishable", "--nocapture"])
+ .env(CHILD, &path).output().unwrap();
+ assert_eq!(output.status.code(), Some(23), "{output:?}");
+ let (complete, pending) = backup_paths(&path);
+ if name == "copy" {
+ assert_eq!(fs::read(&pending).unwrap().len(), 8192);
+ assert!(!complete.exists());
+ assert_eq!(fs::read(&path).unwrap(), original);
+ assert!(check_recovery(&path)
+ .unwrap_err()
+ .to_string()
+ .contains("unfinished backup"));
+ } else {
+ assert!(!pending.exists());
+ assert_eq!(fs::read(&complete).unwrap(), original);
+ assert!(fs::read(&path).unwrap().is_empty());
+ fs::remove_file(&path).unwrap();
+ assert!(write_existing(&path, b"must not recreate").is_err());
+ assert!(!path.exists());
+ assert_eq!(fs::read(&complete).unwrap(), original);
+ }
+ }
+}
+
+#[test]
+fn completed_write_cleanup_failure_and_foreign_backups_are_reported() {
+ use std::os::windows::fs::OpenOptionsExt;
+ use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
+ let dir = Directory::new("cleanup");
+ let path = dir.0.join("config");
+ fs::write(&path, b"original").unwrap();
+ let (complete, _) = backup_paths(&path);
+ fs::write(&complete, b"foreign contents").unwrap();
+ assert!(write_existing(&path, b"rejected").is_err());
+ assert_eq!(fs::read(&complete).unwrap(), b"foreign contents");
+ assert_eq!(fs::read(&path).unwrap(), b"original");
+ fs::remove_file(&complete).unwrap();
+ let mut held = None;
+ let error = write_observed(&path, b"saved", |phase| {
+ if phase == Phase::Committed {
+ held = Some(
+ OpenOptions::new()
+ .read(true)
+ .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
+ .open(&complete)?,
+ );
+ }
+ Ok(())
+ })
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("updated ")
+ && error.to_string().contains("recovery copy remains")
+ );
+ assert_eq!(fs::read(&path).unwrap(), b"saved");
+ assert_eq!(fs::read(&complete).unwrap(), b"original");
+ assert!(write_existing(&path, b"retry").is_err());
+ drop(held);
+}
+
+#[test]
+fn encrypted_config_is_rejected_without_plaintext_backup() {
+ let dir = Directory::new("encrypted");
+ let path = dir.0.join("config");
+ fs::write(&path, b"encrypted original").unwrap();
+ let wide = super::super::extended_length_path(&path).unwrap();
+ assert_ne!(
+ unsafe { EncryptFileW(wide.as_ptr()) },
+ 0,
+ "cannot establish EFS fixture: {}",
+ io::Error::last_os_error()
+ );
+ assert_ne!(
+ fs::metadata(&path).unwrap().file_attributes() & FILE_ATTRIBUTE_ENCRYPTED,
+ 0
+ );
+ let before = security(&path);
+ let before_id = identity(&path);
+ let error = write_existing(&path, b"new").unwrap_err();
+ assert!(error.to_string().contains("encrypted configs"));
+ assert_eq!(fs::read(&path).unwrap(), b"encrypted original");
+ assert_eq!(security(&path), before);
+ assert_eq!(identity(&path), before_id);
+ let (complete, pending) = backup_paths(&path);
+ assert!(!complete.exists() && !pending.exists());
+}