diff --git a/Cargo.lock b/Cargo.lock index 0bfe085..cb19567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,16 +432,18 @@ dependencies = [ [[package]] name = "cortexkit-lease" -version = "0.1.1" +version = "0.1.2" dependencies = [ "fs2", + "windows-sys 0.61.2", ] [[package]] name = "cortexkit-log" -version = "0.3.4" +version = "0.3.5" dependencies = [ "chrono", + "cortexkit-lease", "cortexkit-store-types", "regex", "serde", @@ -611,7 +613,7 @@ dependencies = [ [[package]] name = "cortexkit-store" -version = "0.2.3" +version = "0.2.4" dependencies = [ "cortexkit-lease", "cortexkit-store-types", @@ -637,7 +639,7 @@ dependencies = [ [[package]] name = "cortexkit-test-support" -version = "0.1.1" +version = "0.1.2" dependencies = [ "rustix", "sha2 0.10.9", diff --git a/crates/cortexkit-lease/CHANGELOG.md b/crates/cortexkit-lease/CHANGELOG.md new file mode 100644 index 0000000..b28d29b --- /dev/null +++ b/crates/cortexkit-lease/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 0.1.2 + +- Add an opt-in, Windows-only `test-support` module for native ACL observations, + assertions and disposable broad-ACL fixtures. The feature is off by default. +- Windows files and directories are now owner-only, using protected DACLs that + grant only the current process user full control. New directories pass these + permissions on to their children; reparse points are never adjusted. +- Narrowing existing Windows directories also replaces broad inherited ACLs on + existing descendants, while preserving protected child DACLs. diff --git a/crates/cortexkit-lease/Cargo.toml b/crates/cortexkit-lease/Cargo.toml index 72e4aa9..f9afc90 100644 --- a/crates/cortexkit-lease/Cargo.toml +++ b/crates/cortexkit-lease/Cargo.toml @@ -1,11 +1,25 @@ [package] name = "cortexkit-lease" -version = "0.1.1" +version = "0.1.2" edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true description = "Single-writer durable lease (OS advisory lock + monotonic epoch fence) for CortexKit modules." +[features] +default = [] +# Native Windows ACL observations and fixtures for tests, not production use. +test-support = [] + [dependencies] fs2 = "0.4" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Threading", +] } diff --git a/crates/cortexkit-lease/src/lib.rs b/crates/cortexkit-lease/src/lib.rs index 7d9d783..65fbb0f 100644 --- a/crates/cortexkit-lease/src/lib.rs +++ b/crates/cortexkit-lease/src/lib.rs @@ -35,6 +35,17 @@ use std::{ use fs2::FileExt; +#[cfg(windows)] +mod windows; + +/// Native Windows ACL observations and fixtures for tests only. +/// +/// Available on Windows with the opt-in `test-support` feature. Enable the +/// feature only on a dev-dependency; the fixture helpers deliberately grant +/// broad access and must not be used to configure production permissions. +#[cfg(all(windows, feature = "test-support"))] +pub use windows::test_support; + /// Force owner-only permissions on a file this process owns the lifecycle of. /// /// Files created through `File::create` or `OpenOptions::create` get their mode @@ -79,7 +90,9 @@ pub fn protect_file(path: &std::path::Path) -> std::io::Result<()> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } } - #[cfg(not(unix))] + #[cfg(windows)] + windows::protect_file(path)?; + #[cfg(not(any(unix, windows)))] let _ = path; Ok(()) } @@ -126,7 +139,11 @@ pub fn create_private_dir(dir: &std::path::Path) -> std::io::Result<()> { } Ok(()) } - #[cfg(not(unix))] + #[cfg(windows)] + { + windows::create_private_dir(dir) + } + #[cfg(not(any(unix, windows)))] { std::fs::create_dir_all(dir) } diff --git a/crates/cortexkit-lease/src/windows/mod.rs b/crates/cortexkit-lease/src/windows/mod.rs new file mode 100644 index 0000000..4289fcc --- /dev/null +++ b/crates/cortexkit-lease/src/windows/mod.rs @@ -0,0 +1,328 @@ +//! Owner-only Windows DACLs. All handles are opened without following a final +//! reparse point, and new directories receive their security at creation. + +use std::{ + io, + mem::{size_of, zeroed}, + os::windows::{ffi::OsStrExt, fs::MetadataExt}, + path::Path, + ptr::{null, null_mut}, +}; + +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, ERROR_ALREADY_EXISTS, ERROR_PATH_NOT_FOUND, HANDLE, INVALID_HANDLE_VALUE, + }, + Security::{ + AddAccessAllowedAceEx, + Authorization::{SetSecurityInfo, SE_FILE_OBJECT}, + GetLengthSid, GetTokenInformation, InitializeAcl, InitializeSecurityDescriptor, + SetSecurityDescriptorControl, SetSecurityDescriptorDacl, TokenUser, ACCESS_ALLOWED_ACE, + ACL, ACL_REVISION, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, OBJECT_INHERIT_ACE, + PROTECTED_DACL_SECURITY_INFORMATION, PSID, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, + SE_DACL_PROTECTED, TOKEN_QUERY, TOKEN_USER, + }, + Storage::FileSystem::{ + CreateDirectoryW, CreateFileW, GetFileInformationByHandle, GetFileType, + BY_HANDLE_FILE_INFORMATION, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK, OPEN_EXISTING, + READ_CONTROL, WRITE_DAC, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, +}; + +struct Handle(HANDLE); + +impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: Handle owns a valid handle returned by a successful Win32 call. + unsafe { CloseHandle(self.0) }; + } +} + +fn wide(path: &Path) -> io::Result> { + let mut value: Vec = path.as_os_str().encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path contains NUL", + )); + } + value.push(0); + Ok(value) +} + +struct User(Vec); + +impl User { + fn current() -> io::Result { + let mut token = null_mut(); + // SAFETY: The pseudo process handle is valid and token is a writable output. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = Handle(token); + let mut bytes = 0; + // SAFETY: A null buffer with length zero requests the required buffer size. + unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut bytes) }; + if (bytes as usize) < size_of::() { + return Err(io::Error::last_os_error()); + } + let mut user = Self(vec![0; (bytes as usize).div_ceil(size_of::())]); + // SAFETY: The buffer is pointer-aligned, has at least bytes bytes, and stays + // alive with the embedded SID for as long as User is used. + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + user.0.as_mut_ptr().cast(), + bytes, + &mut bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(user) + } + + fn sid(&self) -> PSID { + // SAFETY: current initialized an aligned TOKEN_USER and its embedded SID. + unsafe { (*(self.0.as_ptr().cast::())).User.Sid } + } +} + +struct OwnerAcl(Vec); + +impl OwnerAcl { + fn new(directory: bool) -> io::Result { + let user = User::current()?; + // SAFETY: user owns a valid token SID for the duration of this call. + let sid_bytes = unsafe { GetLengthSid(user.sid()) } as usize; + let bytes = + size_of::() + size_of::() - size_of::() + sid_bytes; + let mut acl = Self(vec![0; bytes.div_ceil(size_of::())]); + let flags = if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }; + // SAFETY: The u32-aligned ACL buffer is large enough for its header and + // one ACE. AddAccessAllowedAceEx copies the SID before user is dropped. + if unsafe { + InitializeAcl( + acl.as_mut_ptr(), + (acl.0.len() * size_of::()) as u32, + ACL_REVISION, + ) == 0 + || AddAccessAllowedAceEx( + acl.as_mut_ptr(), + ACL_REVISION, + flags, + FILE_ALL_ACCESS, + user.sid(), + ) == 0 + } { + return Err(io::Error::last_os_error()); + } + Ok(acl) + } + + fn as_mut_ptr(&mut self) -> *mut ACL { + self.0.as_mut_ptr().cast() + } +} + +fn open_no_follow(path: &Path) -> io::Result<(Handle, u32)> { + let name = wide(path)?; + // SAFETY: name is NUL-terminated; no security attributes or template are used. + // OPEN_REPARSE_POINT opens the final link itself, not its target. + let raw = unsafe { + CreateFileW( + name.as_ptr(), + READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + null_mut(), + ) + }; + if raw == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let handle = Handle(raw); + // SAFETY: BY_HANDLE_FILE_INFORMATION is a plain Win32 output structure. + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { zeroed() }; + // SAFETY: handle is live and info is a correctly sized writable output. + if unsafe { GetFileInformationByHandle(handle.0, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: handle is live; GetFileType takes no output pointers. + if unsafe { GetFileType(handle.0) } != FILE_TYPE_DISK { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a disk file", + )); + } + Ok((handle, info.dwFileAttributes)) +} + +fn apply(handle: &Handle, directory: bool) -> io::Result<()> { + let mut acl = OwnerAcl::new(directory)?; + // Windows can bypass directory traversal checks. SetSecurityInfo also + // replaces inherited ACEs on existing descendants, so their own ACLs lose + // broad access when the directory is narrowed. Protected child DACLs stay intact. + // SAFETY: handle is live and acl contains a valid DACL. SetSecurityInfo copies + // the DACL, leaving owner/group/SACL unchanged. Protection disables parent ACEs. + let error = unsafe { + SetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl.as_mut_ptr(), + null(), + ) + }; + if error != 0 { + return Err(io::Error::from_raw_os_error(error as i32)); + } + Ok(()) +} + +pub(super) fn protect_file(path: &Path) -> io::Result<()> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{} is not a regular file; refusing to change its permissions", + path.display() + ), + )); + } + let (handle, attributes) = match open_no_follow(path) { + Ok(opened) => opened, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a regular file", + )); + } + apply(&handle, false) +} + +pub(super) fn create_private_dir(dir: &Path) -> io::Result<()> { + create_all(dir)?; + let metadata = std::fs::symlink_metadata(dir)?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Ok(()); + } + // Existing directories may belong to another account. As on Unix, failure + // to tighten them is diagnostic only; failure to create them is an error. + let result = open_no_follow(dir).and_then(|(handle, attributes)| { + if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Ok(()); + } + if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "not a directory", + )); + } + apply(&handle, true) + }); + if let Err(error) = result { + eprintln!( + "cortexkit-lease: {} could not be made owner-only: {error}", + dir.display() + ); + } + Ok(()) +} + +fn create_all(dir: &Path) -> io::Result<()> { + if dir.as_os_str().is_empty() { + return Ok(()); + } + match std::fs::symlink_metadata(dir) { + Ok(metadata) if metadata.is_dir() || dir.is_dir() => return Ok(()), + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "not a directory", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let name = wide(dir)?; + let mut acl = OwnerAcl::new(true)?; + // SAFETY: SECURITY_DESCRIPTOR is a plain Win32 structure initialized below. + let mut descriptor: SECURITY_DESCRIPTOR = unsafe { zeroed() }; + let descriptor_ptr = (&mut descriptor as *mut SECURITY_DESCRIPTOR).cast(); + // SAFETY: descriptor is aligned and writable; acl remains live until after + // CreateDirectoryW copies the descriptor. Revision 1 is SECURITY_DESCRIPTOR_REVISION. + if unsafe { + InitializeSecurityDescriptor(descriptor_ptr, 1) == 0 + || SetSecurityDescriptorDacl(descriptor_ptr, 1, acl.as_mut_ptr(), 0) == 0 + || SetSecurityDescriptorControl(descriptor_ptr, SE_DACL_PROTECTED, SE_DACL_PROTECTED) + == 0 + } { + return Err(io::Error::last_os_error()); + } + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor_ptr, + bInheritHandle: 0, + }; + // SAFETY: name is NUL-terminated and attributes references live descriptor/ACL + // storage. The directory is private from the instant it becomes visible. + if unsafe { CreateDirectoryW(name.as_ptr(), &attributes) } != 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + match error.raw_os_error().map(|code| code as u32) { + Some(ERROR_ALREADY_EXISTS) => { + let metadata = std::fs::symlink_metadata(dir)?; + if metadata.is_dir() + || (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 && dir.is_dir()) + { + Ok(()) + } else { + Err(error) + } + } + Some(ERROR_PATH_NOT_FOUND) => { + let parent = dir.parent().filter(|parent| *parent != dir).ok_or(error)?; + create_all(parent)?; + // SAFETY: The same live name and security attributes are used after + // creating the parent. Concurrent creation is checked without chmod. + if unsafe { CreateDirectoryW(name.as_ptr(), &attributes) } != 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_ALREADY_EXISTS as i32) && dir.is_dir() { + Ok(()) + } else { + Err(error) + } + } + _ => Err(error), + } +} + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; +#[cfg(test)] +mod tests; diff --git a/crates/cortexkit-lease/src/windows/test_support.rs b/crates/cortexkit-lease/src/windows/test_support.rs new file mode 100644 index 0000000..5457594 --- /dev/null +++ b/crates/cortexkit-lease/src/windows/test_support.rs @@ -0,0 +1,300 @@ +//! Native Windows ACL observations and deliberately broad fixtures for tests only. +//! +//! Available through `cortexkit_lease::test_support` with the opt-in +//! `test-support` feature on Windows. Enable it only on dev-dependencies. +//! Expectations come from the process token and ACLs read back from Windows, +//! not from the permission writer. Helpers panic on failed Win32 calls or failed +//! assertions; they are not production permission-management APIs. + +use std::{ + mem::{size_of, zeroed}, + os::windows::ffi::OsStrExt, + path::Path, + ptr::{null, null_mut}, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, LocalFree}, + Security::{ + AclSizeInformation, + Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW, + SetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, + }, + GetAce, GetAclInformation, GetLengthSid, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, GetTokenInformation, TokenUser, ACCESS_ALLOWED_ACE, ACE_HEADER, + ACL, ACL_SIZE_INFORMATION, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, INHERITED_ACE, + OBJECT_INHERIT_ACE, PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + SE_DACL_PROTECTED, TOKEN_QUERY, TOKEN_USER, + }, + Storage::FileSystem::FILE_ALL_ACCESS, + System::Threading::{GetCurrentProcess, OpenProcessToken}, +}; + +struct Descriptor(PSECURITY_DESCRIPTOR); + +impl Drop for Descriptor { + fn drop(&mut self) { + // SAFETY: The descriptor was allocated by an API that requires LocalFree. + unsafe { LocalFree(self.0) }; + } +} + +fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +/// Returns the current process token user's SID as its native binary bytes. +/// +/// Panics if the token or its user information cannot be read. +pub fn current_user_sid() -> Vec { + let mut token = null_mut(); + // SAFETY: token is writable and the current process pseudo handle is valid. + assert_ne!( + unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }, + 0 + ); + let mut bytes = 0; + // SAFETY: A null buffer queries the needed size; token is live. + unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut bytes) }; + assert!(bytes as usize >= size_of::()); + let mut data = vec![0usize; (bytes as usize).div_ceil(size_of::())]; + // SAFETY: data is aligned and large enough for TOKEN_USER and its SID. + let result = unsafe { + GetTokenInformation( + token, + TokenUser, + data.as_mut_ptr().cast(), + bytes, + &mut bytes, + ) + }; + // SAFETY: token was opened successfully and is no longer needed. + unsafe { CloseHandle(token) }; + assert_ne!(result, 0); + // SAFETY: The successful GetTokenInformation(TokenUser) call above + // initialized a TOKEN_USER and its SID inside data. Copy the SID bytes while + // data is still alive. + unsafe { + let sid = (*(data.as_ptr().cast::())).User.Sid; + std::slice::from_raw_parts(sid.cast::(), GetLengthSid(sid) as usize).to_vec() + } +} + +#[derive(Debug, PartialEq, Eq)] +/// An access-allowed ACE observed directly in a named filesystem object's DACL. +pub struct Ace { + /// Native ACE type; `read_acl` rejects types other than ACCESS_ALLOWED (0). + pub kind: u8, + /// Native ACE flags, including object/container inheritance and inherited status. + pub flags: u8, + /// Native access mask, compared to FILE_ALL_ACCESS by `assert_owner_only`. + pub mask: u32, + /// Trustee SID in its native binary representation. + pub sid: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +/// DACL observations copied from the Windows security descriptor. +pub struct Snapshot { + /// Whether SE_DACL_PROTECTED prevents inheritance from the parent. + pub protected: bool, + /// Access entries in their native ACL order. + pub aces: Vec, +} + +/// Reads a named filesystem object's DACL with GetNamedSecurityInfoW. +/// +/// Panics on a failed Win32 call, a null DACL, or any non-ACCESS_ALLOWED ACE. +pub fn read_acl(path: &Path) -> Snapshot { + let name = wide(path); + let mut acl: *mut ACL = null_mut(); + let mut raw = null_mut(); + // SAFETY: name is terminated and both output pointers are writable. Windows + // returns a descriptor owning the ACL, kept alive through all observations. + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut acl, + null_mut(), + &mut raw, + ) + }, + 0, + "read named DACL for {}", + path.display() + ); + let descriptor = Descriptor(raw); + assert!(!acl.is_null(), "a null DACL grants everyone access"); + let mut control = 0; + let mut revision = 0; + // SAFETY: descriptor is live; control and revision are writable outputs. + assert_ne!( + unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) }, + 0 + ); + // SAFETY: ACL_SIZE_INFORMATION is a plain output structure initialized below. + let mut info: ACL_SIZE_INFORMATION = unsafe { zeroed() }; + // SAFETY: acl belongs to the live descriptor and info is correctly sized. + assert_ne!( + unsafe { + GetAclInformation( + acl, + (&mut info as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + }, + 0 + ); + let mut aces = Vec::new(); + for index in 0..info.AceCount { + let mut raw_ace = null_mut(); + // SAFETY: index is within the count obtained from this live ACL. + assert_ne!(unsafe { GetAce(acl, index, &mut raw_ace) }, 0); + // SAFETY: The test fixtures contain ACCESS_ALLOWED ACEs. Check their type + // before reading the corresponding mask and variable-length SID. + unsafe { + let header = &*raw_ace.cast::(); + assert_eq!(header.AceType, 0, "expected ACCESS_ALLOWED_ACE_TYPE"); + let ace = &*raw_ace.cast::(); + let sid: PSID = std::ptr::addr_of!(ace.SidStart).cast_mut().cast(); + aces.push(Ace { + kind: ace.Header.AceType, + flags: ace.Header.AceFlags, + mask: ace.Mask, + sid: std::slice::from_raw_parts(sid.cast::(), GetLengthSid(sid) as usize) + .to_vec(), + }); + } + } + Snapshot { + protected: control & SE_DACL_PROTECTED != 0, + aces, + } +} + +/// Asserts the expected protected flag and exactly one current-token-user ACE +/// granting full control. Directory ACEs must inherit to files and directories; +/// unprotected descendants must carry an actually inherited ACE. +pub fn assert_owner_only(path: &Path, directory: bool, protected: bool) { + let snapshot = read_acl(path); + assert_eq!( + snapshot.protected, + protected, + "DACL protected flag for {}", + path.display() + ); + assert_eq!( + snapshot.aces.len(), + 1, + "DACL must have exactly one access entry for {}", + path.display() + ); + let ace = &snapshot.aces[0]; + assert_eq!(ace.kind, 0, "only ACCESS_ALLOWED entries are permitted"); + assert_eq!( + ace.sid, + current_user_sid(), + "ACE SID must equal the current process token user" + ); + assert_eq!(ace.mask, FILE_ALL_ACCESS, "the user must have full control"); + if directory { + assert_eq!( + u32::from(ace.flags) & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE), + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE, + "directory ACE must cover child files and directories" + ); + } + if !protected { + assert_ne!( + u32::from(ace.flags) & INHERITED_ACE, + 0, + "the child ACE must actually be inherited" + ); + } +} + +/// Gives a test fixture a protected, inheritable Everyone full-control DACL. +/// +/// This deliberately broadens access. Use only on disposable test paths. +/// Panics if Windows cannot apply or read back the fixture ACL. +pub fn grant_everyone(dir: &Path) { + let name = wide(dir); + let sddl: Vec = "D:P(A;OICI;FA;;;WD)" + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut raw = null_mut(); + // SAFETY: sddl is terminated; raw receives a LocalFree-owned descriptor. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut raw, + null_mut(), + ) + }, + 0 + ); + let descriptor = Descriptor(raw); + let mut present = 0; + let mut defaulted = 0; + let mut acl = null_mut(); + // SAFETY: descriptor owns the security descriptor parsed from the SDDL string + // above and frees it only on drop, after this call; the output pointers are + // writable locals. + assert_ne!( + unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut acl, &mut defaulted) }, + 0 + ); + assert_ne!(present, 0); + assert!(!acl.is_null()); + // SAFETY: name and the ACL inside descriptor stay alive for the call. The + // ACL grants Everyone (WD) full control, inherited by files and folders + // (OICI), so the test starts from a deliberately broad directory. It is set + // with the raw Win32 call, not the code under test, so the precondition + // does not depend on what is being tested. + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl, + null(), + ) + }, + 0 + ); + let snapshot = read_acl(dir); + assert_eq!(snapshot.aces.len(), 1); + assert_ne!( + snapshot.aces[0].sid, + current_user_sid(), + "broad fixture must not already be owner-only" + ); +} + +/// Asserts that a fixture is unprotected and inherits an ACE for a non-user SID. +pub fn assert_broad_inherited(path: &Path) { + let snapshot = read_acl(path); + assert!( + !snapshot.protected, + "fixture must inherit the broad parent DACL" + ); + assert!( + snapshot + .aces + .iter() + .any(|ace| ace.sid != current_user_sid() && u32::from(ace.flags) & INHERITED_ACE != 0), + "fixture must include a non-user inherited ACE" + ); +} diff --git a/crates/cortexkit-lease/src/windows/tests.rs b/crates/cortexkit-lease/src/windows/tests.rs new file mode 100644 index 0000000..0e53bfd --- /dev/null +++ b/crates/cortexkit-lease/src/windows/tests.rs @@ -0,0 +1,183 @@ +use super::test_support::{assert_broad_inherited, assert_owner_only, grant_everyone, read_acl}; +use crate::{create_private_dir, protect_file}; +use std::{ + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "cortexkit-windows-acl-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn create_private_dir_has_a_protected_user_only_dacl_and_private_children() { + let root = TempDir::new(); + grant_everyone(&root.0); + let dir = root.0.join("parent").join("owned"); + create_private_dir(&dir).unwrap(); + assert_owner_only(&root.0.join("parent"), true, true); + assert_owner_only(&dir, true, true); + let child = dir.join("inherited.txt"); + std::fs::write(&child, b"private").unwrap(); + // Inheritance alone leaves the child's DACL unprotected, but carries only + // the parent's one user ACE. Explicit protection is tested separately. + assert_owner_only(&child, false, false); + let child_dir = dir.join("inherited-dir"); + std::fs::create_dir(&child_dir).unwrap(); + assert_owner_only(&child_dir, true, false); +} + +#[test] +fn protect_file_replaces_an_inherited_broad_acl_with_a_protected_user_only_dacl() { + let root = TempDir::new(); + grant_everyone(&root.0); + let path = root.0.join("inherited.txt"); + std::fs::write(&path, b"private").unwrap(); + assert_broad_inherited(&path); + protect_file(&path).unwrap(); + assert_owner_only(&path, false, true); +} + +#[test] +fn create_private_dir_narrows_an_existing_broad_directory_without_changing_its_parent() { + let root = TempDir::new(); + grant_everyone(&root.0); + let before = read_acl(&root.0); + let dir = root.0.join("existing"); + std::fs::create_dir(&dir).unwrap(); + assert_broad_inherited(&dir); + create_private_dir(&dir).unwrap(); + assert_owner_only(&dir, true, true); + assert_eq!( + read_acl(&root.0), + before, + "the existing parent must not be narrowed" + ); +} + +#[test] +fn create_private_dir_removes_broad_inherited_acls_from_existing_descendants() { + let root = TempDir::new(); + grant_everyone(&root.0); + let parent_before = read_acl(&root.0); + let dir = root.0.join("existing"); + std::fs::create_dir(&dir).unwrap(); + let direct = dir.join("store.db"); + std::fs::write(&direct, b"existing store").unwrap(); + let nested = dir.join("logs"); + std::fs::create_dir(&nested).unwrap(); + let nested_file = nested.join("existing.log"); + std::fs::write(&nested_file, b"existing log").unwrap(); + for path in [&dir, &direct, &nested, &nested_file] { + assert_broad_inherited(path); + } + + let protected = dir.join("protected"); + std::fs::create_dir(&protected).unwrap(); + grant_everyone(&protected); + let protected_file = protected.join("custom.txt"); + std::fs::write(&protected_file, b"custom security").unwrap(); + let protected_before = read_acl(&protected); + let protected_file_before = read_acl(&protected_file); + assert!(protected_before.protected); + assert_broad_inherited(&protected_file); + + create_private_dir(&dir).unwrap(); + + assert_owner_only(&dir, true, true); + // Windows can bypass directory traversal checks, so the existing files' + // own inherited ACLs must lose the broad entry when the parent is narrowed. + assert_owner_only(&direct, false, false); + assert_owner_only(&nested, true, false); + assert_owner_only(&nested_file, false, false); + assert_eq!(read_acl(&root.0), parent_before); + assert_eq!(read_acl(&protected), protected_before); + assert_eq!(read_acl(&protected_file), protected_file_before); +} + +#[test] +fn protect_file_refuses_non_regular_files_and_directory_reparse_points_are_unchanged() { + let root = TempDir::new(); + let target = root.0.join("target"); + std::fs::create_dir(&target).unwrap(); + grant_everyone(&target); + let before = read_acl(&target); + assert_eq!( + protect_file(&target).unwrap_err().kind(), + std::io::ErrorKind::InvalidInput + ); + let link = root.0.join("junction"); + // A junction is a directory reparse point and needs no symlink privilege, + // so this refusal check runs even on Windows hosts without Developer Mode. + let output = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&link) + .arg(&target) + .output() + .unwrap(); + assert!( + output.status.success(), + "mklink /J failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + protect_file(&link).unwrap_err().kind(), + std::io::ErrorKind::InvalidInput + ); + create_private_dir(&link).unwrap(); + assert_eq!( + read_acl(&target), + before, + "the reparse target's DACL must be untouched" + ); + std::fs::remove_dir(&link).unwrap(); +} + +#[test] +fn protect_file_refuses_file_symlinks_when_the_host_allows_them() { + let root = TempDir::new(); + let target = root.0.join("target.txt"); + std::fs::write(&target, b"unchanged").unwrap(); + grant_everyone(&target); + let before = read_acl(&target); + let link = root.0.join("link.txt"); + if let Err(error) = std::os::windows::fs::symlink_file(&target, &link) { + if error.raw_os_error() == Some(1314) { + eprintln!( + "file symlink requires a privilege; junction refusal is tested unconditionally" + ); + return; + } + panic!("symlink_file: {error}"); + } + assert_eq!( + protect_file(&link).unwrap_err().kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!( + read_acl(&target), + before, + "the symlink target's DACL must be untouched" + ); +} diff --git a/crates/cortexkit-log/CHANGELOG.md b/crates/cortexkit-log/CHANGELOG.md index 6248dd9..2fa275d 100644 --- a/crates/cortexkit-log/CHANGELOG.md +++ b/crates/cortexkit-log/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.3.5 + +- On Windows, log files and newly created log directories are now owner-only: + only the user running the program can open them, as with mode 0600 and 0700 + on Unix. +- When a log directory is narrowed on Windows, files already inside it, + including nested ones, lose broader access too. + ## 0.3.4 - Stop credential query redaction at `)` and `]` so URL wrappers and following diagnostic text survive. diff --git a/crates/cortexkit-log/Cargo.toml b/crates/cortexkit-log/Cargo.toml index 5fdcbc2..8e27214 100644 --- a/crates/cortexkit-log/Cargo.toml +++ b/crates/cortexkit-log/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortexkit-log" -version = "0.3.4" +version = "0.3.5" edition.workspace = true license.workspace = true repository.workspace = true @@ -20,6 +20,7 @@ default = ["store-paths"] store-paths = ["dep:cortexkit-store-types"] [dependencies] +cortexkit-lease = { version = "0.1.2", path = "../cortexkit-lease" } cortexkit-store-types = { version = "0.2.1", path = "../cortexkit-store-types", optional = true } chrono = { version = "0.4", default-features = false, features = ["std"] } regex = "1" @@ -30,3 +31,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tempfile = "3" + +[target.'cfg(windows)'.dev-dependencies] +cortexkit-lease = { version = "0.1.2", path = "../cortexkit-lease", features = ["test-support"] } diff --git a/crates/cortexkit-log/src/segment.rs b/crates/cortexkit-log/src/segment.rs index 475b81e..5705566 100644 --- a/crates/cortexkit-log/src/segment.rs +++ b/crates/cortexkit-log/src/segment.rs @@ -198,13 +198,20 @@ impl SegmentDestination { fn prepare_dir(dir: &Path, enforce_directory_mode: bool) -> io::Result<()> { let existed = dir.exists(); + #[cfg(windows)] + if enforce_directory_mode || !existed { + cortexkit_lease::create_private_dir(dir)?; + } else { + fs::create_dir_all(dir)?; + } + #[cfg(not(windows))] fs::create_dir_all(dir)?; #[cfg(unix)] if enforce_directory_mode || !existed { use std::os::unix::fs::PermissionsExt; fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; } - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] let _ = (enforce_directory_mode, existed); Ok(()) } @@ -218,6 +225,8 @@ fn open_append(path: &Path) -> io::Result { options.mode(0o600); } let file = options.open(path)?; + #[cfg(windows)] + cortexkit_lease::protect_file(path)?; #[cfg(unix)] if file.metadata()?.file_type().is_file() { use std::os::unix::fs::PermissionsExt; @@ -225,3 +234,7 @@ fn open_append(path: &Path) -> io::Result { } Ok(file) } + +#[cfg(all(test, windows))] +#[path = "windows_segment_tests.rs"] +mod windows_tests; diff --git a/crates/cortexkit-log/src/sink.rs b/crates/cortexkit-log/src/sink.rs index 100dc19..713e20b 100644 --- a/crates/cortexkit-log/src/sink.rs +++ b/crates/cortexkit-log/src/sink.rs @@ -139,6 +139,13 @@ fn prepare_parent(path: &Path, enforce_directory_mode: bool) -> io::Result<()> { .parent() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log path has no parent"))?; let existed = parent.exists(); + #[cfg(windows)] + if enforce_directory_mode || !existed { + cortexkit_lease::create_private_dir(parent)?; + } else { + fs::create_dir_all(parent)?; + } + #[cfg(not(windows))] fs::create_dir_all(parent)?; #[cfg(unix)] @@ -146,9 +153,7 @@ fn prepare_parent(path: &Path, enforce_directory_mode: bool) -> io::Result<()> { use std::os::unix::fs::PermissionsExt; fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; } - // Windows has no mode bits to enforce; the per-user data directory's ACL is - // inherited. The inputs exist only for the Unix arm above. - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] let _ = (enforce_directory_mode, existed); Ok(()) @@ -164,6 +169,9 @@ fn open_active(path: &Path) -> io::Result { } let file = options.open(path)?; + #[cfg(windows)] + cortexkit_lease::protect_file(path)?; + #[cfg(unix)] if file.metadata()?.file_type().is_file() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/cortexkit-log/src/tests.rs b/crates/cortexkit-log/src/tests.rs index 73992b3..7604235 100644 --- a/crates/cortexkit-log/src/tests.rs +++ b/crates/cortexkit-log/src/tests.rs @@ -1114,3 +1114,81 @@ fn emit_at_writes_into_the_segment_its_instant_names() { handle.path().display() ); } + +#[cfg(windows)] +use cortexkit_lease::test_support as windows; + +#[cfg(windows)] +#[test] +fn windows_segments_narrow_existing_broad_directories_and_files() { + let temp = TempDir::new().unwrap(); + windows::grant_everyone(temp.path()); + let parent_before = windows::read_acl(temp.path()); + let logs = temp.path().join("logs"); + fs::create_dir(&logs).unwrap(); + windows::assert_broad_inherited(&logs); + let path = logs.join("insula.2026-09-05.log"); + fs::write(&path, b"").unwrap(); + windows::assert_broad_inherited(&path); + let capture = CaptureWriter::default(); + let (layer, handle) = build_test_layer(config(logs.clone(), "insula"), &capture); + let dispatcher = dispatch(layer); + tracing::dispatcher::with_default(&dispatcher, || tracing::info!("private")); + assert_eq!(handle.path(), path); + assert!(fs::read_to_string(&path).unwrap().contains("private")); + windows::assert_owner_only(&logs, true, true); + windows::assert_owner_only(&path, false, true); + assert_eq!(windows::read_acl(temp.path()), parent_before); +} + +#[cfg(windows)] +#[test] +fn windows_new_segment_directories_and_files_are_owner_only() { + let temp = TempDir::new().unwrap(); + windows::grant_everyone(temp.path()); + let logs = temp.path().join("new").join("logs"); + let capture = CaptureWriter::default(); + let (_layer, handle) = build_test_layer(config(logs.clone(), "insula"), &capture); + assert!( + handle.path().is_file(), + "must inspect a real segment, not the stderr fallback" + ); + windows::assert_owner_only(&temp.path().join("new"), true, true); + windows::assert_owner_only(&logs, true, true); + windows::assert_owner_only(&handle.path(), false, true); +} + +#[cfg(windows)] +#[test] +fn windows_line_sink_protects_files_without_narrowing_an_existing_parent() { + let temp = TempDir::new().unwrap(); + windows::grant_everyone(temp.path()); + let before = windows::read_acl(temp.path()); + let path = temp.path().join("stderr.log"); + fs::write(&path, b"").unwrap(); + windows::assert_broad_inherited(&path); + let mut sink = + LineSink::open_at(&path, Retention::default(), fixed_time(FIXED_NOW_MS)).unwrap(); + sink.write_line_at(b"private", fixed_time(FIXED_NOW_MS)) + .unwrap(); + windows::assert_owner_only(&path, false, true); + assert_eq!(windows::read_acl(temp.path()), before); +} + +#[cfg(windows)] +#[test] +fn windows_line_sink_creates_private_directories_and_rotation_files() { + let temp = TempDir::new().unwrap(); + windows::grant_everyone(temp.path()); + let parent = temp.path().join("new").join("logs"); + let path = parent.join("stderr.log"); + let now = fixed_time(FIXED_NOW_MS); + let mut sink = + LineSink::open_at(&path, Retention::from_bytes_for_testing(8, 2, 14), now).unwrap(); + sink.write_line_at(b"first", now).unwrap(); + sink.write_line_at(b"second", now).unwrap(); + windows::assert_owner_only(&temp.path().join("new"), true, true); + windows::assert_owner_only(&parent, true, true); + windows::assert_owner_only(&path, false, true); + windows::assert_owner_only(&sink::rotated_path(&path, 1), false, true); +} diff --git a/crates/cortexkit-log/src/windows_segment_tests.rs b/crates/cortexkit-log/src/windows_segment_tests.rs new file mode 100644 index 0000000..66be73d --- /dev/null +++ b/crates/cortexkit-log/src/windows_segment_tests.rs @@ -0,0 +1,44 @@ +use super::prepare_dir; +use cortexkit_lease::test_support as windows; +use std::fs; +use tempfile::TempDir; + +#[test] +fn enforced_prepare_dir_removes_broad_inherited_acls_from_existing_segments() { + let temp = TempDir::new().unwrap(); + windows::grant_everyone(temp.path()); + let parent_before = windows::read_acl(temp.path()); + let logs = temp.path().join("logs"); + fs::create_dir(&logs).unwrap(); + let segment = logs.join("insula.2026-09-04.log"); + fs::write(&segment, b"existing segment").unwrap(); + let nested = logs.join("archived"); + fs::create_dir(&nested).unwrap(); + let nested_segment = nested.join("insula.2026-09-03.log"); + fs::write(&nested_segment, b"existing nested segment").unwrap(); + for path in [&logs, &segment, &nested, &nested_segment] { + windows::assert_broad_inherited(path); + } + + let protected = logs.join("protected"); + fs::create_dir(&protected).unwrap(); + windows::grant_everyone(&protected); + let protected_file = protected.join("custom.log"); + fs::write(&protected_file, b"custom security").unwrap(); + let protected_before = windows::read_acl(&protected); + let protected_file_before = windows::read_acl(&protected_file); + assert!(protected_before.protected); + windows::assert_broad_inherited(&protected_file); + + // Call directory preparation alone: opening a segment would protect that + // file separately and could hide a failure to propagate the directory ACL. + prepare_dir(&logs, true).unwrap(); + + windows::assert_owner_only(&logs, true, true); + windows::assert_owner_only(&segment, false, false); + windows::assert_owner_only(&nested, true, false); + windows::assert_owner_only(&nested_segment, false, false); + assert_eq!(windows::read_acl(temp.path()), parent_before); + assert_eq!(windows::read_acl(&protected), protected_before); + assert_eq!(windows::read_acl(&protected_file), protected_file_before); +} diff --git a/crates/cortexkit-store/CHANGELOG.md b/crates/cortexkit-store/CHANGELOG.md index 8331c6a..7184ec6 100644 --- a/crates/cortexkit-store/CHANGELOG.md +++ b/crates/cortexkit-store/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.4 + +- On Windows, store files and the store's own directory are now owner-only: + only the user running the program can open them, as with mode 0600 and + 0700 on Unix. +- In-memory databases and SQLite URIs are no longer treated as file paths + when protecting files. They name no file on disk, and on Windows their + `:` and `?` made the open fail. + ## 0.2.3 - Add opt-in `SqliteStore::with_read` for committed, consistent read-only snapshots diff --git a/crates/cortexkit-store/Cargo.toml b/crates/cortexkit-store/Cargo.toml index 9d57a40..7974dc9 100644 --- a/crates/cortexkit-store/Cargo.toml +++ b/crates/cortexkit-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortexkit-store" -version = "0.2.3" +version = "0.2.4" edition.workspace = true license.workspace = true repository.workspace = true @@ -13,5 +13,5 @@ sqlite = ["dep:rusqlite"] [dependencies] cortexkit-store-types = { version = "0.2.1", path = "../cortexkit-store-types" } -cortexkit-lease = { version = "0.1.1", path = "../cortexkit-lease" } +cortexkit-lease = { version = "0.1.2", path = "../cortexkit-lease" } rusqlite = { version = "0.32", features = ["bundled"], optional = true } diff --git a/crates/cortexkit-store/src/lib.rs b/crates/cortexkit-store/src/lib.rs index 7ecd2c4..335322e 100644 --- a/crates/cortexkit-store/src/lib.rs +++ b/crates/cortexkit-store/src/lib.rs @@ -146,9 +146,16 @@ mod sqlite_backend { /// belongs to whoever started the process, so changing its mode would /// reach far beyond the store. pub(crate) fn owns_store_dir(path: &str, parent: &Path) -> bool { - let memory_or_uri = path == ":memory:" || path.is_empty() || path.starts_with("file:"); let no_own_dir = parent.as_os_str().is_empty() || parent == Path::new("."); - !(memory_or_uri || no_own_dir) + !(is_memory_or_uri(path) || no_own_dir) + } + + /// True for an in-memory database or a SQLite URI. Neither names a file + /// on disk under this exact string, so there is no file or directory to + /// create or protect. On Windows such a string is not even a valid file + /// name (`:` and `?`), so treating it as a path fails outright. + pub(crate) fn is_memory_or_uri(path: &str) -> bool { + path == ":memory:" || path.is_empty() || path.starts_with("file:") } /// Options for [`open_sqlite_with`]. @@ -566,9 +573,11 @@ mod sqlite_backend { // hands out an exclusive single-writer lease, so an out-of-band reader // is already outside the contract. That need is a read replica or an // export operation, not a looser file mode. - for suffix in ["", "-wal", "-shm"] { - protect_file(Path::new(&format!("{path}{suffix}"))) - .map_err(|e| StoreError::Backend(e.to_string()))?; + if !is_memory_or_uri(&path) { + for suffix in ["", "-wal", "-shm"] { + protect_file(Path::new(&format!("{path}{suffix}"))) + .map_err(|e| StoreError::Backend(e.to_string()))?; + } } Ok(SqliteStore { diff --git a/crates/cortexkit-test-support/CHANGELOG.md b/crates/cortexkit-test-support/CHANGELOG.md index d33a01e..bbc451c 100644 --- a/crates/cortexkit-test-support/CHANGELOG.md +++ b/crates/cortexkit-test-support/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.1.2 + +- The daemon watcher writes the daemon's PID file atomically (a temporary + file, then a rename). A reader polling for the file could otherwise see it + empty for a moment and fail to parse it. + ## 0.1.1 - Require `subc-os` 0.1.10 or any later 0.1.x instead of exactly 0.1.10, so a diff --git a/crates/cortexkit-test-support/Cargo.toml b/crates/cortexkit-test-support/Cargo.toml index fc44a6a..5b4f2fa 100644 --- a/crates/cortexkit-test-support/Cargo.toml +++ b/crates/cortexkit-test-support/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortexkit-test-support" -version = "0.1.1" +version = "0.1.2" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/cortexkit-test-support/src/daemon_watch.py b/crates/cortexkit-test-support/src/daemon_watch.py index a1c467f..288f5f5 100644 --- a/crates/cortexkit-test-support/src/daemon_watch.py +++ b/crates/cortexkit-test-support/src/daemon_watch.py @@ -42,9 +42,13 @@ def stop(_signal, _frame): daemon = subprocess.Popen(sys.argv[1:], stdin=subprocess.DEVNULL) # Record the daemon's PID separately from the watcher returned by spawn, # so lifecycle tests can assert that the supervised process is gone. + # Written to a temporary name and renamed, so a reader that sees the file + # always sees the whole PID, never an empty file mid-write. if os.environ.get("CORTEXKIT_TEST_DAEMON_PID_FILE"): - from pathlib import Path - Path(os.environ["CORTEXKIT_TEST_DAEMON_PID_FILE"]).write_text(str(daemon.pid)) + target = os.environ["CORTEXKIT_TEST_DAEMON_PID_FILE"] + with open(target + ".tmp", "w") as pid_file: + pid_file.write(str(daemon.pid)) + os.replace(target + ".tmp", target) # Retain descendants while the daemon is alive: a crashing daemon can # reparent its separately grouped modules before the next scan. owned = {}