From 5f5dd8f7da80e853ef02861869df7a84dce8f4a9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 14:38:29 -0600 Subject: [PATCH] feat(store): the data root under LOCALAPPDATA with an owner-only DACL, one lock seam, MoveFileEx (P11c) Windows step 1 and 2 of D-62. Where Unix keeps the books' private folder at mode 0700 and refuses one others can open, Windows now makes the same promise with a DACL: `winsec::set_owner_only` gives the folder (and, inherited, everything made in it) one protected allow entry for this account's SID; `winsec::is_private` reads the effective DACL and owner back and answers false for anything else (a missing DACL, an entry for Everyone), so `Root::open` refuses a loosened root and `Root::setup` makes it private again, exactly as the 0700 checks do. Every question the system cannot answer is "not private". The root itself comes from the shell's known folders (`SHGetKnownFolderPath`: the profile, and LocalAppData wherever it was redirected), never from environment variables the process that started us chose. `rename_new` (the move that must never go over what is there, renameat2 RENAME_NOREPLACE on Linux) is `MoveFileExW` without the replace flag on Windows, written through. File locking sat behind `libc::flock` in ops.rs alone (the rest already used std's File::try_lock, which is LockFileEx on Windows). It now goes through `lock.rs`, which uses only std, so there is no second unsafe copy of either call. New dependency: windows-sys 0.61 behind cfg(windows), the raw bindings only (no runtime), already in the lockfile through other crates; the smallest Microsoft crate that covers SIDs, DACLs, the known-folder API, pipe process ids and MoveFileExW. mise.toml adds the Windows target to the pinned toolchain so `cargo check --target x86_64-pc-windows-msvc` type-checks this arm on Linux. CHOICE (P11c): nothing above the root is checked on Windows (the Unix rule about folders above it being swappable has no Windows authority; the profile's folders belong to the account and the Administrators). Tests: tests/windows_root.rs (Windows job only), the cross-platform `a_viewer_s_presence_is_a_shared_lock_others_can_see` in ops.rs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- Cargo.lock | 1 + crates/ledger-store/Cargo.toml | 16 + crates/ledger-store/src/chain.rs | 5 + crates/ledger-store/src/lib.rs | 3 + crates/ledger-store/src/lock.rs | 36 ++ crates/ledger-store/src/ops.rs | 44 +-- crates/ledger-store/src/root.rs | 84 +++- crates/ledger-store/src/winsec.rs | 461 ++++++++++++++++++++++ crates/ledger-store/tests/windows_root.rs | 124 ++++++ mise.toml | 2 +- 10 files changed, 733 insertions(+), 43 deletions(-) create mode 100644 crates/ledger-store/src/lock.rs create mode 100644 crates/ledger-store/src/winsec.rs create mode 100644 crates/ledger-store/tests/windows_root.rs diff --git a/Cargo.lock b/Cargo.lock index 8ea4bb4..6b18308 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4049,6 +4049,7 @@ dependencies = [ "subtle", "tempfile", "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/ledger-store/Cargo.toml b/crates/ledger-store/Cargo.toml index 0a97134..722cccd 100644 --- a/crates/ledger-store/Cargo.toml +++ b/crates/ledger-store/Cargo.toml @@ -28,6 +28,22 @@ argon2 = "0.6" [target.'cfg(unix)'.dependencies] libc = "0.2" +# Owner-only DACLs, the account's SID, the known-folder API and +# MoveFileExW (P11c, D-62). The raw bindings, no runtime: the smallest +# Microsoft crate that covers them, already in the lockfile. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_Pipes", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_UI_Shell", +] } + [dev-dependencies] tempfile = "3" proptest = "1" diff --git a/crates/ledger-store/src/chain.rs b/crates/ledger-store/src/chain.rs index e864d99..8c87381 100644 --- a/crates/ledger-store/src/chain.rs +++ b/crates/ledger-store/src/chain.rs @@ -188,6 +188,7 @@ impl ChainLog { genesis_text: &str, ) -> Result<(Self, Vec>), ChainError> { let read = read(path, genesis_text)?; + #[cfg(unix)] let new = !path.exists(); let mut options = OpenOptions::new(); options.create(true).append(true); @@ -205,6 +206,10 @@ impl ChainLog { file.set_permissions(std::fs::Permissions::from_mode(0o600))?; } } + #[cfg(windows)] + if !crate::winsec::is_private(path).unwrap_or(false) { + crate::winsec::set_owner_only(path)?; + } if read.torn { file.set_len(read.good_len)?; file.sync_all()?; diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index a6648f3..e0b1502 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -28,6 +28,7 @@ pub mod exports; pub mod form1099; pub mod format; pub mod journal; +pub mod lock; pub mod meta; pub mod ops; pub mod passphrase; @@ -40,6 +41,8 @@ pub mod schema; pub mod snapshot; pub mod store; pub mod uploads; +#[cfg(windows)] +pub mod winsec; pub use blob::BlobStore; pub use error::StoreError; diff --git a/crates/ledger-store/src/lock.rs b/crates/ledger-store/src/lock.rs new file mode 100644 index 0000000..1da55e9 --- /dev/null +++ b/crates/ledger-store/src/lock.rs @@ -0,0 +1,36 @@ +//! File locks, behind one seam (P11c, D-62). +//! +//! ```text +//! GOAL: "some process holds this file" is asked and answered the same +//! way on every system. +//! SOURCE: the standard library's `File::try_lock` and `try_lock_shared` +//! (flock(2) on Unix, LockFileEx on Windows), plan/served.md 3.9 +//! ("the viewer's presence is a shared lock"). +//! INVARIANTS: +//! 1. Only the standard library's locks are used: no second copy of an +//! `unsafe` flock or LockFileEx call lives anywhere else. +//! 2. A lock is let go when its file is closed, by this process ending +//! or not (the system does it; nothing here keeps a record). +//! 3. A question that cannot be answered is answered "not held". +//! ``` + +use std::fs::{File, TryLockError}; + +/// Take a shared lock on `file` without waiting: whether it is now held. +pub fn try_shared(file: &File) -> bool { + file.try_lock_shared().is_ok() +} + +/// Whether another holder keeps a shared lock on `file` (so that an +/// exclusive one can't be taken now). A lock taken to find out is let go +/// at once. +pub fn held_elsewhere(file: &File) -> bool { + match file.try_lock() { + Ok(()) => { + let _ = file.unlock(); + false + } + Err(TryLockError::WouldBlock) => true, + Err(TryLockError::Error(_)) => false, + } +} diff --git a/crates/ledger-store/src/ops.rs b/crates/ledger-store/src/ops.rs index ef13a87..72523e9 100644 --- a/crates/ledger-store/src/ops.rs +++ b/crates/ledger-store/src/ops.rs @@ -238,13 +238,8 @@ impl Presence { /// (None until it has: the viewer makes nothing). pub fn hold(data_dir: &Path) -> Option { let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?; - #[cfg(unix)] - { - use std::os::fd::AsRawFd; - // SAFETY: flock on a descriptor this function owns. - if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 { - return None; - } + if !crate::lock::try_shared(&f) { + return None; } Some(Presence(f)) } @@ -256,23 +251,7 @@ pub fn viewer_open(data_dir: &Path) -> bool { let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else { return false; }; - #[cfg(unix)] - { - use std::os::fd::AsRawFd; - // SAFETY: flock on a descriptor this function owns; a lock taken - // is let go at once (and with the descriptor anyway). - let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0; - if free { - // SAFETY: as above. - unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) }; - } - !free - } - #[cfg(not(unix))] - { - drop(f); - false - } + crate::lock::held_elsewhere(&f) } #[cfg(test)] @@ -308,6 +287,23 @@ mod tests { assert_eq!(requests(dir.path()).len(), KEPT); } + /// The lock seam on every system: a viewer's presence is seen by + /// another handle, shared between viewers, and let go with its file. + #[test] + fn a_viewer_s_presence_is_a_shared_lock_others_can_see() { + let dir = tempfile::tempdir().unwrap(); + assert!(Presence::hold(dir.path()).is_none()); + prepare(dir.path()).unwrap(); + assert!(!viewer_open(dir.path())); + let held = Presence::hold(dir.path()).unwrap(); + assert!(viewer_open(dir.path())); + let second = Presence::hold(dir.path()).unwrap(); + drop(held); + assert!(viewer_open(dir.path())); + drop(second); + assert!(!viewer_open(dir.path())); + } + #[cfg(unix)] #[test] fn the_server_makes_the_folder_and_the_viewer_only_reads_it() { diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 0d0d9f9..c2c8f77 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -179,8 +179,8 @@ impl std::fmt::Display for BookId { } /// This account's home folder, from the password database on Unix (never -/// `$HOME`, which the process that started us chose), or the Windows -/// profile. +/// `$HOME`, which the process that started us chose), or the profile +/// folder the Windows shell reports. pub fn account_home() -> Result { #[cfg(unix)] { @@ -190,11 +190,9 @@ pub fn account_home() -> Result { } #[cfg(windows)] { - // Read from the environment until the Windows build (P11) takes it - // from the known-folder API. - std::env::var_os("USERPROFILE") - .map(PathBuf::from) - .ok_or(RootError::NoHome) + // The known-folder API, never `%USERPROFILE%`, which the process + // that started us chose (the Unix arm's rule). + crate::winsec::profile_folder().ok_or(RootError::NoHome) } #[cfg(not(any(unix, windows)))] { @@ -256,6 +254,15 @@ fn unix_account() -> Option<(PathBuf, Vec)> { /// The platform's data folder under `home`: where the default root sits. pub fn platform_data_dir(home: &Path) -> PathBuf { + // This account's own home on Windows: `%LOCALAPPDATA%` as the shell + // reports it (a redirected profile included). Any other home (tests) + // gets the default place under it. + #[cfg(windows)] + if account_home().is_ok_and(|own| own == home) + && let Some(local) = crate::winsec::local_app_data() + { + return local; + } if cfg!(target_os = "macos") { home.join("Library").join("Application Support") } else if cfg!(windows) { @@ -295,7 +302,7 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result { - let open = check_private_dir(&meta, true, "a folder inside the books")?; + let open = check_private_dir(&dir, &meta, true, "a folder inside the books")?; tighten_if(&dir, open)?; } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { @@ -404,7 +411,7 @@ impl Root { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RootError::Missing), Err(e) => return Err(e.into()), }; - check_private_dir(&meta, false, THE_ROOT)?; + check_private_dir(path, &meta, false, THE_ROOT)?; check_ancestors(path)?; if !path.join(MARKER_FILE).is_file() { return Err(RootError::Unsafe(format!( @@ -473,7 +480,8 @@ impl Root { Ok(meta) => { // Checked before anything is changed: a folder that holds // other things is left exactly as it is. - let open = check_private_dir(&meta, true, "the folder chosen for the books")?; + let open = + check_private_dir(&path, &meta, true, "the folder chosen for the books")?; if !path.join(MARKER_FILE).is_file() && std::fs::read_dir(&path)?.next().is_some() { return Err(RootError::Unsafe( "the folder chosen for the books already holds other things: choose an \ @@ -562,7 +570,7 @@ impl Root { let tightened = match std::fs::symlink_metadata(&dir) { Ok(meta) => tighten_if( &dir, - check_private_dir(&meta, tighten, "a folder inside it")?, + check_private_dir(&dir, &meta, tighten, "a folder inside it")?, )?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { make_private_dir(&dir)?; @@ -1223,6 +1231,8 @@ impl Root { ))); } } + // Windows: a move to another disk is refused by the move + // itself (no copy fallback is asked for). // A vault or its key among the other paths would go aside // unchecked: each comes by its own name, and is checked. let k = items.len(); @@ -1473,10 +1483,17 @@ fn rename_new(from: &Path, to: &Path) -> std::io::Result<()> { return Err(e); } } - if std::fs::symlink_metadata(to).is_ok() { - return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists)); + // Windows: MoveFileExW without the replace flag refuses an existing + // target itself. + #[cfg(windows)] + return crate::winsec::rename_new(from, to); + #[cfg(not(windows))] + { + if std::fs::symlink_metadata(to).is_ok() { + return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists)); + } + std::fs::rename(from, to) } - std::fs::rename(from, to) } /// Whether the stored files under `old` (a books' `blobs/`) are all in @@ -1771,6 +1788,12 @@ fn recorded_root(home: &Path) -> Result, RootError> { return Err(unsafe_pointer()); } } + // Windows: this account's own file that no one else can write (the + // DACL says who can open it at all; INVARIANT 3). + #[cfg(windows)] + if !crate::winsec::is_private(&pointer).unwrap_or(false) { + return Err(unsafe_pointer()); + } let text = std::fs::read_to_string(&pointer)?; let path = PathBuf::from(text.trim_end_matches(['\n', '\r'])); if path.is_absolute() { @@ -1868,9 +1891,16 @@ fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) } -#[cfg(not(unix))] +/// Windows: the mode is the DACL, this account's alone, protected, and +/// inherited into a folder's contents (`mode` says only that the caller +/// wants it private; a folder or a file is told by what `path` is). +#[cfg(windows)] +fn set_mode(path: &Path, _mode: u32) -> std::io::Result<()> { + crate::winsec::set_owner_only(path) +} + +#[cfg(not(any(unix, windows)))] fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> { - // Windows: the owner-only DACL comes with the Windows build (P11). Ok(()) } @@ -1879,10 +1909,12 @@ fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> { /// to make 0700 with [`tighten_if`]; without, it is refused. `what` names /// it in messages. fn check_private_dir( + path: &Path, meta: &std::fs::Metadata, tighten: bool, what: &str, ) -> Result { + let _ = path; // Unix reads the mode from `meta`; Windows reads the DACL at `path`. if meta.file_type().is_symlink() { return Err(RootError::Unsafe(format!( "{what} is a symlink: it must be a real folder" @@ -1910,8 +1942,18 @@ fn check_private_dir( return Ok(true); } } - #[cfg(not(unix))] - let _ = tighten; + // Windows: this account's own, with no one else on its access list. + #[cfg(windows)] + if !crate::winsec::is_private(path).unwrap_or(false) { + if !tighten { + return Err(RootError::Unsafe(format!( + "other users can open {what}: run `wanderbooks setup` to make it private" + ))); + } + return Ok(true); + } + #[cfg(not(any(unix, windows)))] + let _ = (path, tighten); Ok(false) } @@ -1929,6 +1971,11 @@ fn check_ancestors(path: &Path) -> Result<(), RootError> { /// sticky bit (like `/tmp`); group write only when the group is this /// account's own private group (its primary group, with no other members: /// the usual umask-002 setup on many Linux systems). +/// +/// Windows: nothing above the root is checked. The root sits under the +/// account's own profile, whose folders the system gives that account +/// and the Administrators alone; another place is the owner's choice +/// (CHOICE, P11c: no authority states a Windows rule for ancestors). fn check_dir_and_above(dir: &Path) -> Result<(), RootError> { #[cfg(unix)] { @@ -1999,6 +2046,7 @@ fn is_private_group(gid: u32) -> bool { /// it is the account's primary group, is named as the account's login is, /// and lists no members. A shared primary group (openSUSE's `users`) has /// another name, so it isn't. +#[cfg(unix)] fn private_group_rule(gid: u32, primary: u32, name: &[u8], login: &[u8], members: bool) -> bool { gid == primary && name == login && !members } diff --git a/crates/ledger-store/src/winsec.rs b/crates/ledger-store/src/winsec.rs new file mode 100644 index 0000000..bcf0638 --- /dev/null +++ b/crates/ledger-store/src/winsec.rs @@ -0,0 +1,461 @@ +//! Windows owner-only security (P11c, D-62): what mode 0700/0600 and +//! `getuid` are on Unix. +//! +//! ```text +//! GOAL: the data root, its folders and files, and the local pipes are +//! this account's alone, and a process can say whose another +//! process is. +//! SOURCE: plan/served.md 3.4 ("the data root ... an owner-only DACL +//! where Unix has mode 0700"), 3.9 ("named pipes with the SID +//! check where Unix checks peer uid"), D-62; Microsoft's +//! documentation of SetNamedSecurityInfoW, SDDL strings, +//! GetNamedPipeClientProcessId and SHGetKnownFolderPath (cited, +//! not copied). +//! INVARIANTS: +//! 1. An owner-only DACL is protected (it takes nothing from the folder +//! above) and holds one allow entry, for this account's SID; +//! folders' entry is inherited by what is made inside them. +//! 2. A path is private when its owner is this account (or the +//! Administrators group a elevated account's files are owned by) +//! and no allow entry names anyone but this account, SYSTEM or the +//! Administrators; a missing DACL (everyone allowed) is not private. +//! 3. Every question that cannot be answered (a handle that won't open, +//! a token that won't read) is "no": nothing here fails open. +//! ``` + +use std::ffi::{OsStr, OsString, c_void}; +use std::io; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::path::{Path, PathBuf}; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW, +}; +use windows_sys::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetLengthSid, GetSecurityDescriptorDacl, + GetTokenInformation, IsWellKnownSid, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, + TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid, +}; +use windows_sys::Win32::System::Com::CoTaskMemFree; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION, +}; +use windows_sys::Win32::UI::Shell::{ + FOLDERID_LocalAppData, FOLDERID_Profile, KF_FLAG_DEFAULT, SHGetKnownFolderPath, +}; + +/// An ACCESS_ALLOWED_ACE_TYPE entry's type byte. +const ACCESS_ALLOWED: u8 = 0; + +/// A handle closed when dropped. +struct Owned(HANDLE); + +impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: a handle this value opened and owns, closed once. + unsafe { CloseHandle(self.0) }; + } +} + +/// A SID: an owned copy of its bytes. +#[derive(Clone, Debug)] +pub struct Sid(Vec); + +impl Sid { + /// A copy of the SID `sid` points at. + /// + /// # Safety + /// `sid` must point at a valid SID. + unsafe fn copy_of(sid: PSID) -> Sid { + // SAFETY: the caller's promise that `sid` is a valid SID. + let len = unsafe { GetLengthSid(sid) } as usize; + let mut words = vec![0u64; len.div_ceil(8)]; + // SAFETY: `len` bytes of a valid SID into a buffer of at least + // `len` bytes; the buffer is 8-byte aligned. + unsafe { std::ptr::copy_nonoverlapping(sid.cast::(), words.as_mut_ptr().cast(), len) }; + Sid(words) + } + + fn as_psid(&self) -> PSID { + self.0.as_ptr() as PSID + } + + /// The SID as the text SDDL uses (`S-1-5-21-...`). + pub fn to_text(&self) -> io::Result { + let mut out: *mut u16 = std::ptr::null_mut(); + // SAFETY: a valid SID, and an out pointer to receive an allocation. + if unsafe { ConvertSidToStringSidW(self.as_psid(), &mut out) } == 0 { + return Err(io::Error::last_os_error()); + } + let text = from_wide_ptr(out); + // SAFETY: the allocation ConvertSidToStringSidW made, freed once. + unsafe { LocalFree(out.cast()) }; + text.into_string() + .map_err(|_| io::Error::other("a SID that is not text")) + } +} + +impl PartialEq for Sid { + fn eq(&self, other: &Sid) -> bool { + // SAFETY: both are valid SIDs copied from the system. + unsafe { EqualSid(self.as_psid(), other.as_psid()) != 0 } + } +} + +impl Eq for Sid {} + +/// The text a NUL-terminated wide string holds. +fn from_wide_ptr(p: *const u16) -> OsString { + let mut len = 0; + // SAFETY: the caller passes a NUL-terminated string the system made. + while unsafe { *p.add(len) } != 0 { + len += 1; + } + // SAFETY: `len` units were just read from it. + OsString::from_wide(unsafe { std::slice::from_raw_parts(p, len) }) +} + +/// A path or text as a NUL-terminated wide string. +pub fn wide(s: &OsStr) -> Vec { + s.encode_wide().chain(std::iter::once(0)).collect() +} + +/// The user SID of the token `token` (TOKEN_QUERY). +fn token_user(token: HANDLE) -> io::Result { + let mut needed = 0u32; + // SAFETY: asking only for the size; the call is expected to fail + // with "insufficient buffer" and set `needed`. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) }; + if needed == 0 { + return Err(io::Error::last_os_error()); + } + // 8-byte aligned for TOKEN_USER's pointers. + let mut buf = vec![0u64; (needed as usize).div_ceil(8)]; + // SAFETY: a buffer of at least `needed` bytes. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buf.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: the buffer now holds a TOKEN_USER whose SID points inside it. + Ok(unsafe { Sid::copy_of((*buf.as_ptr().cast::()).User.Sid) }) +} + +/// This process's account. +pub fn current_user() -> io::Result { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle of this process, and an out handle. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = Owned(token); + token_user(token.0) +} + +/// The account process `pid` runs as. An error when it can't be told +/// (a process of another account usually can't be opened at all). +pub fn process_user(pid: u32) -> io::Result { + // SAFETY: a plain open of a process by number. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err(io::Error::last_os_error()); + } + let process = Owned(process); + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: a process handle just opened, and an out handle. + if unsafe { OpenProcessToken(process.0, TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = Owned(token); + token_user(token.0) +} + +/// A security descriptor the system allocated, freed when dropped. +struct Descriptor(PSECURITY_DESCRIPTOR); + +impl Drop for Descriptor { + fn drop(&mut self) { + // SAFETY: an allocation of the system's, freed once. + unsafe { LocalFree(self.0) }; + } +} + +/// The descriptor of SDDL `text`. +fn from_sddl(text: &str) -> io::Result { + let wide = wide(OsStr::new(text)); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: a NUL-terminated string and an out pointer. + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Descriptor(sd)) +} + +/// The SDDL of a DACL that holds one allow entry, full access for this +/// account, protected; for a folder, inherited by what it holds. +fn owner_only_sddl(folder: bool) -> io::Result { + let sid = current_user()?.to_text()?; + Ok(if folder { + format!("D:P(A;OICI;FA;;;{sid})") + } else { + format!("D:P(A;;FA;;;{sid})") + }) +} + +/// Make `path` this account's alone (INVARIANT 1): the mode 0700 of a +/// folder, the 0600 of a file. +pub fn set_owner_only(path: &Path) -> io::Result<()> { + let folder = std::fs::metadata(path)?.is_dir(); + let sd = from_sddl(&owner_only_sddl(folder)?)?; + let mut present = 0; + let mut defaulted = 0; + let mut dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: a descriptor the system made, and out values. + if unsafe { GetSecurityDescriptorDacl(sd.0, &mut present, &mut dacl, &mut defaulted) } == 0 { + return Err(io::Error::last_os_error()); + } + let name = wide(path.as_os_str()); + // SAFETY: a NUL-terminated path, no owner or group to set, and a DACL + // that lives in `sd` past the call. + let status = unsafe { + SetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null(), + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(()) +} + +/// Whether the owner and the allow entries of `path` are this account's +/// (INVARIANT 2): the answer to "do others have access". +pub fn is_private(path: &Path) -> io::Result { + let me = current_user()?; + let name = wide(path.as_os_str()); + let mut owner: PSID = std::ptr::null_mut(); + let mut dacl: *mut ACL = std::ptr::null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: a NUL-terminated path and out pointers; the descriptor the + // call allocates holds the owner and the DACL and is freed below. + let status = unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + &mut dacl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + let _free = Descriptor(sd); + // SAFETY: `owner` points into `sd`, alive here. + let owner_ok = !owner.is_null() + && (unsafe { EqualSid(owner, me.as_psid()) } != 0 + || unsafe { IsWellKnownSid(owner, WinBuiltinAdministratorsSid) } != 0); + if !owner_ok || dacl.is_null() { + return Ok(false); + } + // SAFETY: `dacl` points into `sd`; its entries are read in place. + let count = unsafe { (*dacl).AceCount }; + for i in 0..u32::from(count) { + let mut ace: *mut c_void = std::ptr::null_mut(); + // SAFETY: an index below the entry count of a valid ACL. + if unsafe { GetAce(dacl, i, &mut ace) } == 0 { + return Ok(false); + } + // SAFETY: every ACE starts with its header. + let header = unsafe { *ace.cast::() }; + if header.AceType != ACCESS_ALLOWED { + // Deny entries only take access away; the object-specific + // allow types don't occur on files, and one is not private. + if (1..=3).contains(&header.AceType) { + continue; + } + return Ok(false); + } + // SAFETY: an ACCESS_ALLOWED_ACE's SID starts at SidStart. + let sid = unsafe { + std::ptr::addr_of_mut!( + (*ace.cast::()).SidStart + ) + } + .cast::(); + // SAFETY: a valid SID inside the ACE. + let ours = unsafe { + EqualSid(sid, me.as_psid()) != 0 + || IsWellKnownSid(sid, WinLocalSystemSid) != 0 + || IsWellKnownSid(sid, WinBuiltinAdministratorsSid) != 0 + }; + if !ours { + return Ok(false); + } + } + Ok(true) +} + +/// A known folder's path, from the shell (never an environment variable +/// the process that started us chose). +fn known_folder(id: &windows_sys::core::GUID) -> Option { + let mut out: *mut u16 = std::ptr::null_mut(); + // SAFETY: a known-folder id, the current account's token (null), and an + // out pointer for a string the shell allocates. + let hr = + unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT as u32, std::ptr::null_mut(), &mut out) }; + if hr != 0 || out.is_null() { + if !out.is_null() { + // SAFETY: freed once, as the API asks even on failure. + unsafe { CoTaskMemFree(out.cast()) }; + } + return None; + } + let path = PathBuf::from(from_wide_ptr(out)); + // SAFETY: the shell's allocation, freed once. + unsafe { CoTaskMemFree(out.cast()) }; + path.is_absolute().then_some(path) +} + +/// This account's profile folder (`C:\Users\name`). +pub fn profile_folder() -> Option { + known_folder(&FOLDERID_Profile) +} + +/// This account's local application data folder (`%LOCALAPPDATA%`, +/// wherever it was redirected to). +pub fn local_app_data() -> Option { + known_folder(&FOLDERID_LocalAppData) +} + +/// Rename `from` to `to`, never over something already at `to`, and +/// written through to disk. +pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> { + use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW}; + let (f, t) = (wide(from.as_os_str()), wide(to.as_os_str())); + // SAFETY: two NUL-terminated paths; no REPLACE_EXISTING flag, so an + // existing target is an error. + if unsafe { MoveFileExW(f.as_ptr(), t.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Security attributes that make a kernel object (a named pipe) this +/// account's alone, for a call that takes `SECURITY_ATTRIBUTES`. +pub struct OwnerOnly { + _descriptor: Descriptor, + attributes: SECURITY_ATTRIBUTES, +} + +// SAFETY: the descriptor is immutable memory that nothing else touches. +unsafe impl Send for OwnerOnly {} +unsafe impl Sync for OwnerOnly {} + +impl OwnerOnly { + /// A descriptor with one allow entry, full access for this account. + pub fn new() -> io::Result { + let sddl = format!("D:P(A;;GA;;;{})", current_user()?.to_text()?); + let descriptor = from_sddl(&sddl)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + Ok(OwnerOnly { + _descriptor: descriptor, + attributes, + }) + } + + /// The pointer `create_with_security_attributes_raw` takes; good while + /// this lives. + pub fn as_raw(&self) -> *mut c_void { + std::ptr::addr_of!(self.attributes).cast_mut().cast() + } +} + +/// This process's account, asked of the system once. +fn me() -> Option<&'static Sid> { + static ME: std::sync::OnceLock> = std::sync::OnceLock::new(); + ME.get_or_init(|| current_user().ok()).as_ref() +} + +/// Whether process `pid` runs as this account (INVARIANT 3: any doubt is +/// "no"). +pub fn pid_is_us(pid: u32) -> bool { + pid != 0 && me().is_some_and(|me| process_user(pid).is_ok_and(|them| &them == me)) +} + +/// The process at the client end of the pipe `handle` (a server's). +pub fn pipe_client_pid(handle: std::os::windows::io::RawHandle) -> io::Result { + let mut pid = 0u32; + // SAFETY: a pipe handle the caller owns and an out value. + if unsafe { windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId(handle, &mut pid) } + == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(pid) +} + +/// The process at the server end of the pipe `handle` (a client's). +pub fn pipe_server_pid(handle: std::os::windows::io::RawHandle) -> io::Result { + let mut pid = 0u32; + // SAFETY: a pipe handle the caller owns and an out value. + if unsafe { windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId(handle, &mut pid) } + == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(pid) +} + +/// How many bytes wait to be read on the pipe `handle`, without reading +/// them. A pipe whose other end has gone is `ERROR_BROKEN_PIPE`. +pub fn pipe_bytes_waiting(handle: std::os::windows::io::RawHandle) -> io::Result { + let mut avail = 0u32; + // SAFETY: a pipe handle the caller owns; only the available count is + // asked for. + let ok = unsafe { + windows_sys::Win32::System::Pipes::PeekNamedPipe( + handle, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut avail, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(avail) +} diff --git a/crates/ledger-store/tests/windows_root.rs b/crates/ledger-store/tests/windows_root.rs new file mode 100644 index 0000000..d9b016d --- /dev/null +++ b/crates/ledger-store/tests/windows_root.rs @@ -0,0 +1,124 @@ +//! The data root on Windows (P11c, D-62): the same promises as Unix's +//! mode 0700, kept with an owner-only DACL. Runs on the Windows job only. +#![cfg(windows)] + +use ledger_store::root::{Root, RootError, default_root}; +use ledger_store::winsec; +use std::path::Path; + +fn tmp(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("wb-winroot-{tag}-")) + .tempdir() + .unwrap() +} + +#[test] +fn the_default_root_sits_under_local_app_data() { + let home = tmp("default"); + assert_eq!( + default_root(home.path()), + home.path() + .join("AppData") + .join("Local") + .join("wanderbooks") + ); + // This account's own home: the shell's LocalAppData, wherever it is. + let own = ledger_store::root::account_home().unwrap(); + assert_eq!( + default_root(&own), + winsec::local_app_data().unwrap().join("wanderbooks") + ); +} + +#[test] +fn setup_makes_a_private_root_and_its_folders() { + let home = tmp("setup"); + let (root, report) = Root::setup(home.path(), None, None).unwrap(); + assert_eq!(root.path(), default_root(home.path())); + assert!(report.created && !report.tightened && !report.recorded); + assert!(winsec::is_private(root.path()).unwrap()); + for sub in ["books", "run", "legacy"] { + assert!(winsec::is_private(&root.path().join(sub)).unwrap(), "{sub}"); + } + // Again: nothing to do. + let (_, again) = Root::setup(home.path(), None, None).unwrap(); + assert!(!again.created && !again.tightened && !again.recorded); + assert!(Root::find_in(home.path(), None).is_ok()); +} + +/// Let everyone into `path` and what it holds, as `icacls` does. +fn open_to_everyone(path: &Path) { + let out = std::process::Command::new("icacls") + .arg(path) + .args(["/grant", "*S-1-1-0:(OI)(CI)F"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); +} + +#[test] +fn a_root_others_can_open_is_refused_and_setup_makes_it_private_again() { + let home = tmp("loose"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + open_to_everyone(root.path()); + assert!(!winsec::is_private(root.path()).unwrap()); + assert!(matches!(Root::open(root.path()), Err(RootError::Unsafe(_)))); + let (_, report) = Root::setup(home.path(), None, None).unwrap(); + assert!(report.tightened); + assert!(winsec::is_private(root.path()).unwrap()); + assert!(Root::open(root.path()).is_ok()); +} + +#[test] +fn what_is_made_in_a_private_folder_is_private() { + let home = tmp("inherit"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let file = root.path().join("books").join("note.txt"); + std::fs::write(&file, b"x").unwrap(); + assert!(winsec::is_private(&file).unwrap()); + let sub = root.path().join("books").join("inner"); + std::fs::create_dir(&sub).unwrap(); + assert!(winsec::is_private(&sub).unwrap()); +} + +#[test] +fn a_file_made_owner_only_is_private_and_a_loosened_one_is_not() { + let dir = tmp("file"); + let file = dir.path().join("f"); + std::fs::write(&file, b"x").unwrap(); + winsec::set_owner_only(&file).unwrap(); + assert!(winsec::is_private(&file).unwrap()); + let out = std::process::Command::new("icacls") + .arg(&file) + .args(["/grant", "*S-1-1-0:R"]) + .output() + .unwrap(); + assert!(out.status.success(), "{out:?}"); + assert!(!winsec::is_private(&file).unwrap()); +} + +#[test] +fn a_rename_never_goes_over_what_is_there() { + let dir = tmp("rename"); + let (a, b, c) = ( + dir.path().join("a"), + dir.path().join("b"), + dir.path().join("c"), + ); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&b, b"b").unwrap(); + let err = winsec::rename_new(&a, &b).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists, "{err:?}"); + assert_eq!(std::fs::read(&b).unwrap(), b"b"); + winsec::rename_new(&a, &c).unwrap(); + assert_eq!(std::fs::read(&c).unwrap(), b"a"); + assert!(!a.exists()); +} + +#[test] +fn this_process_runs_as_the_account_that_the_token_says() { + let me = winsec::current_user().unwrap(); + assert_eq!(winsec::process_user(std::process::id()).unwrap(), me); + assert!(me.to_text().unwrap().starts_with("S-1-5-")); +} diff --git a/mise.toml b/mise.toml index 0cd80e8..e5adda6 100644 --- a/mise.toml +++ b/mise.toml @@ -4,7 +4,7 @@ [tools] # Workspace toolchain (clippy and rustfmt for `just check`). rust = [ - { version = "1.99", components = "clippy,rustfmt" }, + { version = "1.99", components = "clippy,rustfmt", targets = "x86_64-pc-windows-msvc" }, # Fuzzing (cargo-fuzz) and Miri need nightly; pinned so runs repeat. { version = "nightly-2026-10-01", components = "miri,rust-src" }, ]