From 5f5dd8f7da80e853ef02861869df7a84dce8f4a9 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 14:38:29 -0600 Subject: [PATCH 01/12] 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" }, ] From bd6448532eddd8916ab1109fcba28c156bc19f1a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:02:12 -0600 Subject: [PATCH 02/12] feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c) Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge, the opener, the books server and the command line is gone: they build on every OS, and the Unix behaviour is unchanged (the existing over-the-wire suites pass on Linux). `ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600 sockets as before, peer uid unchanged (the peer functions moved here). Windows: a named pipe `\\.\pipe\wanderbooks--` made with a DACL of one allow entry for this account's SID, remote clients refused, the first instance made once (a second server is refused, as a held lock refuses it on Unix), and 16 idle instances kept waiting so a client never finds all busy while the server makes the next. Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and compares that process's token user with its own SID; any doubt is "not us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot act as the client. A blocking client with a read limit is polled with PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has gone reads as the end of the stream, as a closed socket does. Stop signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has SIGTERM, SIGINT and SIGHUP. The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, leaving the opener's job when the job lets it: an agent's job must not take the host down, as an app's systemd scope must not on Linux) with a fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A replaced program is told by size and last write. Git for Windows is found and a quiet git is stopped with its tree (`taskkill /T`). The test harness of ledger-cli finds its root by `default_root`, cleans a child's environment per OS and reaches the server over its pipe; the bridge's scripted-server tests stay Unix-only. No new dependency (tokio's named pipes and reqwest's `windows_named_pipe` were already in the build); `windows-sys` stays in ledger-store. Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only), ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants of the host and start tests. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/src/main.rs | 35 +- crates/ledger-cli/tests/common/mod.rs | 92 ++- crates/ledger-cli/tests/one_executable.rs | 62 +- crates/ledger-cli/tests/startup.rs | 3 +- crates/ledger-gui/src/receipts.rs | 4 + crates/ledger-gui/tests/read_only.rs | 19 +- crates/ledger-host/src/bridge.rs | 41 +- crates/ledger-host/src/channel.rs | 19 + crates/ledger-host/src/commands.rs | 54 +- crates/ledger-host/src/host.rs | 191 ++++-- crates/ledger-host/src/lib.rs | 6 +- crates/ledger-host/src/opener.rs | 20 +- crates/ledger-host/src/start.rs | 176 ++++- crates/ledger-host/src/tokens.rs | 3 + crates/ledger-host/tests/windows_pipe.rs | 53 ++ crates/ledger-mcp/src/admin.rs | 79 ++- crates/ledger-mcp/src/ipc.rs | 793 ++++++++++++++++++++++ crates/ledger-mcp/src/lib.rs | 2 +- crates/ledger-mcp/src/local.rs | 164 +---- crates/ledger-store/src/backup.rs | 41 +- crates/ledger-store/src/winsec.rs | 24 +- 21 files changed, 1471 insertions(+), 410 deletions(-) create mode 100644 crates/ledger-host/src/channel.rs create mode 100644 crates/ledger-host/tests/windows_pipe.rs create mode 100644 crates/ledger-mcp/src/ipc.rs diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 9ea981f..b9173fb 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -59,10 +59,10 @@ fn gui_args(args: Vec) -> Vec { /// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the /// command line does it (the books server, the agents' own commands). -#[cfg(all(feature = "gui", unix))] +#[cfg(feature = "gui")] struct ViewerOwner(ledger_host::commands::Common); -#[cfg(all(feature = "gui", unix))] +#[cfg(feature = "gui")] impl ledger_gui::owner::Owner for ViewerOwner { fn close_books(&self) -> Result { ledger_host::commands::close_books(&self.0) @@ -124,7 +124,6 @@ impl ledger_gui::owner::Owner for ViewerOwner { #[cfg(feature = "gui")] fn run_gui(args: Vec) -> i32 { let args = gui_args(args); - #[cfg(unix)] { let value = |flag: &str| { // The last given, as the viewer reads it. @@ -256,7 +255,6 @@ fn run_setup(args: Vec) -> i32 { /// After setup: the owner passphrase, asked for at a terminal if none is /// set yet; elsewhere (a script, an agent) only said how to set it. -#[cfg(unix)] fn owner_passphrase(root: &ledger_store::root::Root) -> i32 { let auth = match root.auth_dir() { Ok(dir) => dir, @@ -294,14 +292,8 @@ fn owner_passphrase(root: &ledger_store::root::Root) -> i32 { } } -#[cfg(not(unix))] -fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 { - 0 -} - /// `wanderbooks server`: the books server for the data root, on its /// local sockets (plan 3.9). The host starts it. -#[cfg(unix)] fn run_server(args: Vec) -> i32 { match ledger_mcp::local::run("wanderbooks server", args) { Ok(()) => 0, @@ -317,7 +309,6 @@ fn run_server(args: Vec) -> i32 { /// The modes that open and hold the books (plan 3.9): the host, the /// agents' bridge, and the owner's commands. -#[cfg(unix)] fn run_local(mode: &str, args: Vec) -> i32 { let prog = format!("wanderbooks {mode}"); let result = match mode { @@ -345,7 +336,6 @@ fn run_local(mode: &str, args: Vec) -> i32 { /// `upload` and `download`: the agent's file commands, whose words may /// be paths that aren't text. -#[cfg(unix)] fn run_files(mode: &str, args: Vec) -> i32 { let prog = format!("wanderbooks {mode}"); let result = match mode { @@ -364,27 +354,6 @@ fn run_files(mode: &str, args: Vec) -> i32 { } } -/// The file commands, where local sockets aren't built yet. -#[cfg(not(unix))] -fn run_files(_mode: &str, _args: Vec) -> i32 { - eprintln!("error: opening the books this way comes to this system with P11"); - 2 -} - -/// The modes that hold the books, where local sockets aren't built yet. -#[cfg(not(unix))] -fn run_local(_mode: &str, _args: Vec) -> i32 { - eprintln!("error: opening the books this way comes to this system with P11"); - 2 -} - -/// `wanderbooks server` where local sockets aren't built yet. -#[cfg(not(unix))] -fn run_server(_args: Vec) -> i32 { - eprintln!("error: the books server's local sockets come to this system with P11"); - 2 -} - fn main() { // A file's path may be any name the system takes: the file commands // get the words as given. Everything else is text. diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index 3fb84a1..981f4d2 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -63,14 +63,10 @@ impl Home { .prefix(&format!("wb-{tag}-")) .tempdir() .unwrap(); - let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) - .args(["setup", "--home"]) - .arg(dir.path()) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", dir.path()) - .output() - .unwrap(); + let mut setup = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + setup.args(["setup", "--home"]).arg(dir.path()); + clean_env(&mut setup, dir.path()); + let out = setup.output().unwrap(); assert!(out.status.success(), "{out:?}"); Self { dir, @@ -79,7 +75,7 @@ impl Home { } pub fn root(&self) -> PathBuf { - self.dir.path().join(".local/share/wanderbooks") + ledger_store::root::default_root(self.dir.path()) } pub fn run(&self) -> PathBuf { @@ -190,14 +186,14 @@ impl Server { .append(true) .open(home.dir.path().join("server.log")) .unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + let mut command = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + command .arg("server") .arg("--root") .arg(home.root()) - .current_dir(cwd) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", home.dir.path()) + .current_dir(cwd); + clean_env(&mut command, home.dir.path()); + let mut child = command .env("XDG_CONFIG_HOME", home.dir.path().join(".config")) .envs(env.iter().map(|(k, v)| (*k, v.as_os_str()))) .stdin(Stdio::piped()) @@ -248,10 +244,10 @@ pub fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder { /// [`mcp_http_builder`], for the root's `run/` folder `run`. pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder { // The short path is good in this process only, while it lives: kept. - let short = ledger_store::root::SocketPath::new(run, "mcp.sock").unwrap(); - let path = short.path().to_path_buf(); - std::mem::forget(short); - reqwest::Client::builder().unix_socket(path) + let endpoint = ledger_mcp::ipc::Endpoint::new(run, "mcp.sock").unwrap(); + let builder = ledger_host::channel::http_builder(&endpoint); + std::mem::forget(endpoint); + builder } /// The settings a settings file's text gives, as books.set_setting changes @@ -836,20 +832,71 @@ pub fn book_files(dir: &Path) -> std::collections::BTreeMap { /// Copy the tree `from` into `to`, private as the server keeps a book /// (folders 0700, files 0600). pub fn copy_private(from: &Path, to: &Path) { - use std::os::unix::fs::PermissionsExt; std::fs::create_dir_all(to).unwrap(); - std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o700)).unwrap(); + private_folder(to); for entry in std::fs::read_dir(from).unwrap().flatten() { let (src, dst) = (entry.path(), to.join(entry.file_name())); if entry.file_type().unwrap().is_dir() { copy_private(&src, &dst); } else { std::fs::copy(&src, &dst).unwrap(); - std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)).unwrap(); + private_file(&dst); } } } +/// Make the folder `dir` private as the server keeps one (0700; on +/// Windows this account's alone, by an owner-only DACL). +pub fn private_folder(dir: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + #[cfg(windows)] + ledger_store::winsec::set_owner_only(dir).unwrap(); +} + +/// Make the file `path` private as the server keeps one (0600; on +/// Windows this account's alone). +pub fn private_file(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + #[cfg(windows)] + ledger_store::winsec::set_owner_only(path).unwrap(); +} + +/// A child process's environment cleared down to what a program needs to +/// run and to find `home` as its own: PATH and HOME on Unix; the system +/// folder, a PATH of the system's, the profile and the temporary folder on +/// Windows (a program started with nothing at all can't load its DLLs). +pub fn clean_env(command: &mut Command, home: &Path) { + command.env_clear(); + #[cfg(unix)] + { + command.env("PATH", "/usr/bin:/bin").env("HOME", home); + } + #[cfg(windows)] + { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); + let system32 = Path::new(&root).join("System32"); + command + .env("SystemRoot", &root) + .env("windir", &root) + .env( + "PATH", + std::env::join_paths([system32, root.clone().into()]).unwrap(), + ) + .env("USERPROFILE", home) + .env("HOME", home) + .env("TEMP", std::env::temp_dir()) + .env("TMP", std::env::temp_dir()); + } +} + /// A test's place: a home (whose folder holds the test's own files, its /// settings file among them) and the books [`BOOK`] in its root, served /// by a server [`Place::start`] starts, and starts again on each next @@ -912,8 +959,7 @@ impl Place { let dir = self.books_data(); let fresh = !dir.join("journal.jsonl").exists(); if dir.exists() { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + private_folder(&dir); } let cwd = self.cwd.borrow().clone(); let cwd = cwd.as_deref().unwrap_or(self.home.dir.path()); diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index f9134e9..91434b8 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -2,6 +2,7 @@ //! (`server`) and the viewer (`gui`) as modes of itself, each with its own //! flags, and the server-only build has no viewer. +#[cfg_attr(not(unix), allow(unused_imports))] use std::process::{Command, Stdio}; #[cfg(all(feature = "gui", target_os = "linux"))] @@ -105,13 +106,12 @@ fn a_viewer_that_cannot_open_a_window_exits_1() { let server = common::Server::start(&home); home.create(common::BOOK, "USD"); assert!(server.close().success()); - let mut child = wanderbooks() - .args(["gui", "--root"]) + let mut gui = wanderbooks(); + gui.args(["gui", "--root"]) .arg(home.root()) - .args(["--book", common::BOOK]) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", home.dir.path()) + .args(["--book", common::BOOK]); + common::clean_env(&mut gui, home.dir.path()); + let mut child = gui .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() @@ -147,7 +147,6 @@ fn the_server_only_build_has_no_viewer() { } /// The books server needs a data root: with none, it says to run setup. -#[cfg(unix)] #[test] fn the_books_server_needs_a_root() { // No root yet: the books server says to run setup. @@ -258,52 +257,3 @@ fn the_file_commands_end_with_the_code_that_says_how() { ); assert!(!root.exists(), "the books were reached for"); } - -/// Where local sockets aren't built yet (P11, Windows): the modes that -/// hold the books say so and end with 2, never a success; setup makes -/// the folder and ends with 0, asking for no passphrase (nothing there -/// asks for one yet). -#[cfg(not(unix))] -#[test] -fn without_local_sockets_the_modes_that_hold_the_books_say_so() { - for mode in [ - "server", - "host", - "mcp", - "serve", - "close", - "books", - "doctor", - "token", - "connect", - "passphrase", - "upload", - "download", - ] { - let out = wanderbooks() - .arg(mode) - .stdin(Stdio::null()) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); - assert!( - String::from_utf8_lossy(&out.stderr).contains("to this system with P11"), - "{mode}: {out:?}" - ); - } - let home = tempfile::Builder::new() - .prefix("wb-one-nosock-") - .tempdir() - .unwrap(); - let out = wanderbooks() - .args(["setup", "--home"]) - .arg(home.path()) - .env_remove("XDG_DATA_HOME") - .stdin(Stdio::null()) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(0), "{out:?}"); - let text = String::from_utf8_lossy(&out.stdout); - assert!(text.contains("The books' private folder: made"), "{text}"); - assert!(!text.contains("passphrase"), "{text}"); -} diff --git a/crates/ledger-cli/tests/startup.rs b/crates/ledger-cli/tests/startup.rs index 66310fa..cbd7dee 100644 --- a/crates/ledger-cli/tests/startup.rs +++ b/crates/ledger-cli/tests/startup.rs @@ -15,10 +15,9 @@ mod common; /// New books `id` written in `home`'s root by the store itself, before a /// server opens them; the store, still open. fn written(home: &Home, id: &str) -> ledger_store::Store { - use std::os::unix::fs::PermissionsExt; let dir = home.book_dir(id); let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap(); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + common::private_folder(&dir); store } diff --git a/crates/ledger-gui/src/receipts.rs b/crates/ledger-gui/src/receipts.rs index e4e3822..7bf1631 100644 --- a/crates/ledger-gui/src/receipts.rs +++ b/crates/ledger-gui/src/receipts.rs @@ -380,6 +380,10 @@ fn keep(path: &std::path::Path, png: &[u8]) { use std::os::unix::fs::PermissionsExt; let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); } + // Windows: this account's alone (an owner-only DACL, inherited by the + // thumbnails written inside). + #[cfg(windows)] + let _ = ledger_store::winsec::set_owner_only(dir); // One name per write, across viewers (the pid) and this one's workers. static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let tmp = dir.join(format!( diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 4af8710..dd993dc 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -19,6 +19,7 @@ use ledger_gui::data::{self, Loader, Update}; use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::{App, Source}; use std::collections::BTreeMap; +#[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; @@ -46,22 +47,36 @@ fn tree(root: &Path) -> Tree { } /// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755. +/// On Windows a file gets its read-only attribute (which stops a write +/// to it) and a folder is left as it is: a folder can't be made +/// read-only there. fn set_read_only(root: &Path, read_only: bool) { fn walk(p: &Path, ro: bool) { let m = std::fs::symlink_metadata(p).unwrap(); if m.is_dir() { + #[cfg(unix)] if !ro { std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); } for e in std::fs::read_dir(p).unwrap() { walk(&e.unwrap().path(), ro); } + #[cfg(unix)] if ro { std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap(); } } else { - let mode = if ro { 0o444 } else { 0o644 }; - std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap(); + #[cfg(unix)] + { + let mode = if ro { 0o444 } else { 0o644 }; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap(); + } + #[cfg(windows)] + { + let mut permissions = m.permissions(); + permissions.set_readonly(ro); + std::fs::set_permissions(p, permissions).unwrap(); + } } } walk(root, read_only); diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index cf9d013..09f08b4 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -162,8 +162,8 @@ struct Bridge { next_id: std::sync::atomic::AtomicU64, /// The agent's requests not yet answered, by id (INVARIANT 8). pending: Mutex>, - /// The server's socket, for its owner check (INVARIANT 9). - socket: PathBuf, + /// The server's channel, for its owner check (INVARIANT 9). + endpoint: ledger_mcp::ipc::Endpoint, } /// Who has the server's socket, when it isn't plainly this account. @@ -1034,9 +1034,9 @@ impl Bridge { /// Whether the server's socket belongs to this account (INVARIANT 9). fn socket_is_ours(&self) -> Result<(), Owner> { - let stream = std::os::unix::net::UnixStream::connect(&self.socket) - .map_err(|_| Owner::Unreachable)?; - if ledger_mcp::local::peer_is_us(&stream) { + let stream = + ledger_mcp::ipc::connect_std(&self.endpoint).map_err(|_| Owner::Unreachable)?; + if ledger_mcp::ipc::peer_is_us(&stream) { Ok(()) } else { Err(Owner::Other( @@ -1314,18 +1314,16 @@ pub fn run( .map_err(|e| e.to_string())? .to_string(); let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?; - // Kept for the whole session: the client reaches the socket by it. - let socket = - ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET) - .map_err(|e| format!("can't reach the books server: {e}"))?; + // Kept for the whole session: the client reaches the channel by it. + let endpoint = ledger_mcp::ipc::Endpoint::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET) + .map_err(|e| format!("can't reach the books server: {e}"))?; let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()?; let result = rt.block_on(async move { // A connection per call: one the server closes while idle can't // swallow a call (INVARIANT 4). - let http = reqwest::Client::builder() - .unix_socket(socket.path()) + let http = crate::channel::http_builder(&endpoint) .pool_max_idle_per_host(0) .no_proxy() .build() @@ -1347,7 +1345,7 @@ pub fn run( tools_seen: Mutex::new(None), next_id: std::sync::atomic::AtomicU64::new(1), pending: Mutex::new(std::collections::HashMap::new()), - socket: socket.path().to_path_buf(), + endpoint, }); let keeper = tokio::spawn(bridge.clone().keep(opener)); let writer = tokio::spawn(async move { @@ -1363,12 +1361,7 @@ pub fn run( // A client that stops the bridge with a signal (most do) still has // its session ended upstream (INVARIANT 10). let stop = Arc::new(Notify::new()); - for kind in [ - tokio::signal::unix::SignalKind::terminate(), - tokio::signal::unix::SignalKind::interrupt(), - tokio::signal::unix::SignalKind::hangup(), - ] { - let mut signals = tokio::signal::unix::signal(kind).map_err(|e| e.to_string())?; + for mut signals in ledger_mcp::ipc::stop_signals().map_err(|e| e.to_string())? { let stop = stop.clone(); tokio::spawn(async move { if signals.recv().await.is_some() { @@ -1537,7 +1530,7 @@ mod tests { /// The bridge against a scripted server on a Unix socket: every way an /// answer can come back, and the bridge's own bookkeeping. -#[cfg(test)] +#[cfg(all(test, unix))] mod against_a_server { use super::*; use std::collections::{HashMap, VecDeque}; @@ -1669,8 +1662,12 @@ mod against_a_server { } fn bridge_at(socket: &Path, knobs: Knobs) -> (Arc, mpsc::UnboundedReceiver) { - let http = reqwest::Client::builder() - .unix_socket(socket) + let endpoint = ledger_mcp::ipc::Endpoint::new( + socket.parent().unwrap(), + socket.file_name().and_then(|n| n.to_str()).unwrap(), + ) + .unwrap(); + let http = crate::channel::http_builder(&endpoint) .pool_max_idle_per_host(0) .no_proxy() .build() @@ -1693,7 +1690,7 @@ mod against_a_server { tools_seen: Mutex::new(None), next_id: std::sync::atomic::AtomicU64::new(1), pending: Mutex::new(std::collections::HashMap::new()), - socket: socket.to_path_buf(), + endpoint, }); (bridge, rx) } diff --git a/crates/ledger-host/src/channel.rs b/crates/ledger-host/src/channel.rs new file mode 100644 index 0000000..935618d --- /dev/null +++ b/crates/ledger-host/src/channel.rs @@ -0,0 +1,19 @@ +//! The HTTP client for the books server's channel (plan 3.9, D-62): a +//! Unix socket on Unix, a named pipe on Windows. The server speaks HTTP +//! on it; only how a client reaches it differs. + +use ledger_mcp::ipc::Endpoint; + +/// A client builder that connects to `endpoint` whatever URL it is given +/// (`http://localhost/...`). On Unix the endpoint must outlive the client: +/// a long socket path is reached through a handle it keeps open. +pub fn http_builder(endpoint: &Endpoint) -> reqwest::ClientBuilder { + #[cfg(unix)] + { + reqwest::Client::builder().unix_socket(endpoint.path().to_path_buf()) + } + #[cfg(windows)] + { + reqwest::Client::builder().windows_named_pipe(endpoint.pipe_name().to_string()) + } +} diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index e08056d..dfba42e 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -302,12 +302,7 @@ pub fn serve( // Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever // serve is doing (opening, waiting to try again): none is missed. let stop = std::sync::Arc::new(tokio::sync::Notify::new()); - for kind in [ - tokio::signal::unix::SignalKind::interrupt(), - tokio::signal::unix::SignalKind::terminate(), - tokio::signal::unix::SignalKind::hangup(), - ] { - let mut signals = tokio::signal::unix::signal(kind)?; + for mut signals in ledger_mcp::ipc::stop_signals()? { let stop = stop.clone(); tokio::spawn(async move { while signals.recv().await.is_some() { @@ -872,8 +867,13 @@ fn offer_upgrades(run: &Path) { /// or a job in the background, would wait unseen for an answer, and mix /// the question into its output). fn offer_upgrade(run: &Path, book: &str, waiting: &admin::FormatWaiting) { + // Windows has no foreground process group to ask about: a terminal + // is all there is to see. + #[cfg(unix)] // SAFETY: isatty, tcgetpgrp and getpgrp have no preconditions. let seen = unsafe { libc::isatty(1) == 1 && libc::tcgetpgrp(0) == libc::getpgrp() }; + #[cfg(windows)] + let seen = std::io::IsTerminal::is_terminal(&std::io::stdout()); if !(seen && can_type_a_passphrase()) { eprintln!( "note: the books \"{book}\" wait for a format upgrade (to {}): until the owner agrees, with `wanderbooks books upgrade {book}` at a terminal or in the app, they work on their old format.", @@ -1160,6 +1160,20 @@ pub fn connect( } } +/// Whether `p` is a file someone may run (Windows: a file; what it can +/// run as is told by its extension, which the caller chose). +fn is_runnable(p: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + std::fs::metadata(p).is_ok_and(|m| m.is_file()) + } +} + /// Let the agent `agent` ("claude" or "codex") reach the books `book`: /// their token kept in its token file (made if none is), and `wanderbooks /// mcp --book ` added to the agent's own list as @@ -1176,7 +1190,7 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result Result Result>() }) + .find(|p| is_runnable(p)) .ok_or_else(|| { format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)") })?; @@ -1610,8 +1634,7 @@ fn vault_beside(source: &Path) -> Option { /// Whether standard input is a terminal. fn at_a_terminal() -> bool { - // SAFETY: isatty has no preconditions. - unsafe { libc::isatty(0) == 1 } + std::io::IsTerminal::is_terminal(&std::io::stdin()) } /// `wanderbooks books adopt` (INVARIANT 3). @@ -2397,10 +2420,9 @@ fn on_the_socket( ) })?; let token = zeroize::Zeroizing::new(token); - let socket = ledger_store::root::SocketPath::new(&run_dir, ledger_mcp::local::MCP_SOCKET) + let socket = ledger_mcp::ipc::Endpoint::new(&run_dir, ledger_mcp::local::MCP_SOCKET) .map_err(|e| format!("can't reach the books server: {e}"))?; - let http = reqwest::Client::builder() - .unix_socket(socket.path()) + let http = crate::channel::http_builder(&socket) .no_proxy() .build() .map_err(|e| e.to_string())?; diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index 7921311..c3a3706 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -53,6 +53,7 @@ use crate::knobs::{FLAG, Knobs}; use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason}; use ledger_store::root::Root; use std::collections::BTreeMap; +#[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -80,7 +81,23 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024; /// The exit code when another host holds the root. pub const EXIT_ANOTHER_HOST: i32 = 3; +/// File options for what the host makes: mode 0600 on Unix; on Windows +/// the file takes the owner-only DACL its folder carries (P11c). +fn private_options() -> std::fs::OpenOptions { + #[allow(unused_mut)] + let mut options = std::fs::OpenOptions::new(); + #[cfg(unix)] + options.mode(0o600); + options +} + +/// Windows passes no descriptor to a child unless asked to, so there is +/// nothing to close (INVARIANT 2). +#[cfg(not(unix))] +fn close_stray_descriptors() {} + /// Close every descriptor above 2 (INVARIANT 2). +#[cfg(unix)] fn close_stray_descriptors() { #[cfg(target_os = "linux")] { @@ -112,12 +129,7 @@ fn confined(run: &Path) -> Option<&'static str> { } } let probe = run.join(format!(".host-probe-{}", std::process::id())); - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&probe) - { + match private_options().write(true).create_new(true).open(&probe) { Ok(_) => { let _ = std::fs::remove_file(&probe); None @@ -138,11 +150,10 @@ fn take_host_lock(run: &Path) -> Result, String> { /// [`take_host_lock`], waiting at most `wait` (tests shorten it). fn take_host_lock_within(run: &Path, wait: Duration) -> Result, String> { let path = run.join(lease::HOST_LOCK); - let file = std::fs::OpenOptions::new() + let file = private_options() .create(true) .truncate(false) .write(true) - .mode(0o600) .open(&path) .map_err(|e| format!("can't open the host lock: {}", e.kind()))?; let deadline = std::time::Instant::now() + wait; @@ -180,11 +191,7 @@ fn open_log_within(path: &Path, limit: u64) -> std::io::Result { if std::fs::metadata(path).is_ok_and(|m| m.len() > limit) { let _ = std::fs::rename(path, path.with_extension("log.1")); } - std::fs::OpenOptions::new() - .create(true) - .append(true) - .mode(0o600) - .open(path) + private_options().create(true).append(true).open(path) } /// The executable the server is started from (INVARIANT 3). @@ -196,9 +203,13 @@ fn children_executable() -> PathBuf { } } +/// A program file's identity: what changes when the file is replaced. +type Identity = (u64, u64); + /// The running executable's file identity, and its file's now: they /// differ once the file was replaced (INVARIANT 7). -fn running_identity() -> Option<(u64, u64)> { +#[cfg(unix)] +fn running_identity() -> Option { let running = if cfg!(target_os = "linux") { std::fs::metadata("/proc/self/exe").ok()? } else { @@ -207,19 +218,46 @@ fn running_identity() -> Option<(u64, u64)> { Some((running.dev(), running.ino())) } +/// Windows can't replace a program that runs, so an update puts the new +/// file at the old path (the old one moved aside): the identity is the +/// file's size and last write at the path, read when the host started. +#[cfg(windows)] +fn running_identity() -> Option { + identity_of(&std::env::current_exe().ok()?) +} + /// The identity of the file at this executable's path, if it is a /// program (a file half-written or gone doesn't count yet). -fn file_identity() -> Option<(u64, u64)> { +fn file_identity() -> Option { identity_of(&crate::start::this_executable().ok()?) } /// The identity (device, inode) of the program at `path`: a file someone /// may run; else None. -fn identity_of(path: &Path) -> Option<(u64, u64)> { +#[cfg(unix)] +fn identity_of(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino())) } +/// The identity (size, last write in nanoseconds) of the program at +/// `path`: a file; else None. +#[cfg(windows)] +fn identity_of(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let written = meta + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()?; + meta.is_file().then(|| { + ( + meta.len(), + u64::try_from(written.as_nanos()).unwrap_or(u64::MAX), + ) + }) +} + /// Wait at most `wait` for a host to answer on the lease socket: whether /// one did. fn wait_for_a_host(run: &Path, wait: Duration) -> bool { @@ -347,19 +385,29 @@ pub fn run( /// Start the next host from the file now on disk, with this one's /// arguments and (already clean) environment (INVARIANT 7). fn restart() -> Result<(), String> { - use std::os::unix::process::CommandExt; let exe = crate::start::this_executable()?; let mut c = std::process::Command::new(exe); c.args(std::env::args_os().skip(1)) - .current_dir("/") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()); - // SAFETY: setsid is async-signal-safe and touches only the child. - unsafe { - c.pre_exec(|| { - libc::setsid(); - Ok(()) - }); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.current_dir("/"); + // SAFETY: setsid is async-signal-safe and touches only the child. + unsafe { + c.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // Detached from this console and its Ctrl+C, as a new session is + // on Unix. + c.creation_flags(crate::start::DETACHED_HOST); } c.spawn() .map(drop) @@ -456,20 +504,11 @@ impl Host { /// Bind the lease socket, start the server, and serve leases until /// the books close. async fn serve(mut self) -> Result { - let listener = bind(&self.run)?; + let mut listener = bind(&self.run)?; let (events, mut inbox) = mpsc::unbounded_channel::(); let connections = TaskTracker::new(); - let mut signals = Vec::new(); - for kind in [ - tokio::signal::unix::SignalKind::terminate(), - tokio::signal::unix::SignalKind::interrupt(), - tokio::signal::unix::SignalKind::hangup(), - ] { - signals.push( - tokio::signal::unix::signal(kind) - .map_err(|e| format!("can't watch for signals: {e}"))?, - ); - } + let signals = + ledger_mcp::ipc::stop_signals().map_err(|e| format!("can't watch for signals: {e}"))?; let (stop_tx, mut stop) = mpsc::unbounded_channel::<()>(); for mut s in signals { let stop_tx = stop_tx.clone(); @@ -513,9 +552,9 @@ impl Host { } } accepted = listener.accept() => match accepted { - Ok((stream, _)) if ledger_mcp::local::peer_is_us(&stream) => { + Ok(stream) if ledger_mcp::ipc::peer_is_us(&stream) => { // The holder's process, as the kernel knows it. - let pid = ledger_mcp::local::peer_pid(&stream); + let pid = ledger_mcp::ipc::peer_pid(&stream); connections.spawn(connection(stream, pid, events.clone())); } Ok(_) => {} @@ -674,7 +713,9 @@ impl Host { let log = open_log(&self.run.join(SERVER_LOG)) .map_err(|e| format!("can't open the server's log: {}", e.kind()))?; let mut command = tokio::process::Command::new(children_executable()); - command.arg0("wanderbooks").arg("server"); + #[cfg(unix)] + command.arg0("wanderbooks"); + command.arg("server"); if self.explicit_root { command.arg("--root").arg(self.root.path()); } @@ -815,11 +856,10 @@ fn consented(socket: &Path, book: String, passphrase: ledger_mcp::admin::Passphr /// Take run/update.lock (exclusive), if it is free now. fn take_update_lock(run: &Path) -> Result { - let file = std::fs::OpenOptions::new() + let file = private_options() .create(true) .truncate(false) .write(true) - .mode(0o600) .open(run.join(lease::UPDATE_LOCK)) .map_err(|e| format!("can't open the update lock: {}", e.kind()))?; match file.try_lock() { @@ -834,31 +874,22 @@ fn take_update_lock(run: &Path) -> Result { } /// Bind run/lease.sock, replacing a stale one (only the host holding the -/// lock gets here). -fn bind(run: &Path) -> Result { - let path = run.join(lease::LEASE_SOCKET); - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("can't replace the lease socket: {}", e.kind())), - } - let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET) +/// lock gets here); on Windows the lease pipe of the root. +fn bind(run: &Path) -> Result { + let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET) .map_err(|e| format!("can't make the lease socket: {e}"))?; - let listener = tokio::net::UnixListener::bind(short.path()) - .map_err(|e| format!("can't make the lease socket: {}", e.kind()))?; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .map_err(|e| format!("can't make the lease socket private: {}", e.kind()))?; - Ok(listener) + ledger_mcp::ipc::Listener::bind(&endpoint) + .map_err(|e| format!("can't make the lease socket: {}", e.kind())) } /// One connection: its first line, then a lease or one answer /// (lease.rs INVARIANT 1). async fn connection( - stream: tokio::net::UnixStream, + stream: ledger_mcp::ipc::Accepted, peer_pid: Option, events: mpsc::UnboundedSender, ) { - let (read, mut write) = stream.into_split(); + let (read, mut write) = ledger_mcp::ipc::split_accepted(stream); let mut reader = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 + 1)); let mut first = String::new(); let answer = |msg: HostMsg| lease::line(&msg); @@ -1019,13 +1050,31 @@ mod tests { "123456" ); assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log"); - use std::os::unix::fs::PermissionsExt; - assert_eq!( - std::fs::metadata(&log).unwrap().permissions().mode() & 0o777, - 0o600 - ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&log).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } } + #[cfg(windows)] + #[test] + fn a_folder_the_host_can_write_isnt_a_sandbox() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(confined(dir.path()), None); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + 0, + "its probe went" + ); + // A folder that isn't there: no sign of a sandbox. + assert_eq!(confined(&dir.path().join("gone")), None); + } + + #[cfg(unix)] #[test] fn a_folder_the_host_can_write_isnt_a_sandbox() { let seccomp = std::fs::read_to_string("/proc/self/status") @@ -1061,6 +1110,24 @@ mod tests { assert_eq!(confined(&dir.path().join("gone")), None); } + #[cfg(windows)] + #[test] + fn only_a_file_has_an_identity_and_a_replaced_one_another() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("wanderbooks.exe"); + std::fs::write(&file, "x").unwrap(); + let first = identity_of(&file).expect("a file"); + // Replaced by another file (as an update does): another identity. + let next = dir.path().join("next.exe"); + std::fs::write(&next, "yy").unwrap(); + std::fs::rename(&next, &file).unwrap(); + let second = identity_of(&file).unwrap(); + assert_ne!(first, second); + assert_eq!(identity_of(dir.path()), None, "a folder isn't one"); + assert_eq!(identity_of(&dir.path().join("gone")), None); + } + + #[cfg(unix)] #[test] fn only_a_program_file_has_an_identity() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/ledger-host/src/lib.rs b/crates/ledger-host/src/lib.rs index cb4f568..94054e0 100644 --- a/crates/ledger-host/src/lib.rs +++ b/crates/ledger-host/src/lib.rs @@ -11,17 +11,13 @@ //! viewer's Books menu does). Agents' tokens are kept in files under the data root //! ([`tokens`]). -#[cfg(unix)] pub mod bridge; -#[cfg(unix)] +pub mod channel; pub mod commands; -#[cfg(unix)] pub mod host; pub mod knobs; pub mod lease; -#[cfg(unix)] pub mod opener; -#[cfg(unix)] mod start; pub mod tokens; diff --git a/crates/ledger-host/src/opener.rs b/crates/ledger-host/src/opener.rs index 53eba6e..e063856 100644 --- a/crates/ledger-host/src/opener.rs +++ b/crates/ledger-host/src/opener.rs @@ -109,8 +109,8 @@ pub struct Opener { pub struct Lease { /// Its number. pub id: u64, - lines: tokio::io::Lines>>, - _write: tokio::net::unix::OwnedWriteHalf, + lines: tokio::io::Lines>>, + _write: ledger_mcp::ipc::ConnectedWrite, } impl Lease { @@ -207,11 +207,11 @@ impl Opener { let mut started: Option = None; loop { wait_for_restart(&self.run).await; - let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET) + let endpoint = ledger_mcp::ipc::Endpoint::new(&self.run, lease::LEASE_SOCKET) .map_err(|e| format!("can't reach the books' host: {e}"))?; - match tokio::net::UnixStream::connect(short.path()).await { + match ledger_mcp::ipc::connect(&endpoint).await { Ok(stream) => { - if !ledger_mcp::local::peer_is_us(&stream) { + if !ledger_mcp::ipc::peer_is_us(&stream) { return Err( "the books' host socket belongs to another account: not used".into(), ); @@ -279,8 +279,8 @@ impl Opener { } /// Say hello on a connection and read the host's first word. - async fn ask(&self, stream: tokio::net::UnixStream) -> Result { - let (read, mut write) = stream.into_split(); + async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result { + let (read, mut write) = ledger_mcp::ipc::split_connected(stream); let hello = Hello { proto: lease::PROTO, ask: Ask::Hold { @@ -402,14 +402,14 @@ pub fn ask_once(run: &Path, ask: Ask) -> Result, String> { /// [`ask_once`], waiting at most `wait` for the answer (a probe). pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result, String> { use std::io::{BufRead, Write}; - let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET) + let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET) .map_err(|e| format!("can't reach the books' host: {e}"))?; - let stream = match std::os::unix::net::UnixStream::connect(short.path()) { + let stream = match ledger_mcp::ipc::connect_std(&endpoint) { Ok(s) => s, Err(e) if no_host(&e) => return Ok(None), Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())), }; - if !ledger_mcp::local::peer_is_us(&stream) { + if !ledger_mcp::ipc::peer_is_us(&stream) { return Err("the books' host socket belongs to another account: not used".into()); } stream diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 47f7bae..56bb78b 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -28,18 +28,34 @@ //! ``` use crate::knobs::Knobs; +#[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +#[cfg(unix)] use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +#[cfg(unix)] use std::time::{Duration, Instant}; /// The host's PATH (INVARIANT 1). +#[cfg(unix)] const PATH: &str = "/usr/local/bin:/usr/bin:/bin"; /// Where systemd-run may be (never looked up on the opener's PATH). +#[cfg(unix)] const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"]; +/// Windows: a process of its own, without this console, its Ctrl+C or its +/// window (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP): what `setsid` +/// is on Unix. +#[cfg(windows)] +pub(crate) const DETACHED_HOST: u32 = 0x0000_0008 | 0x0000_0200; +/// Windows: leave the job the opener runs in, if it lets its processes +/// (CREATE_BREAKAWAY_FROM_JOB): a host must not end with an agent's job, +/// as it must not end with an app's scope on Linux (INVARIANT 3). +#[cfg(windows)] +const BREAKAWAY: u32 = 0x0100_0000; + /// The host's log, in the root's `run/`. pub const HOST_LOG: &str = "host.log"; /// A log larger than this is moved aside when a host starts. @@ -60,11 +76,13 @@ pub fn this_executable() -> Result { } /// This account's user ID. +#[cfg(unix)] fn own_uid() -> u32 { // SAFETY: geteuid has no preconditions and can't fail. unsafe { libc::geteuid() } } +#[cfg(unix)] /// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private /// runtime folder: absolute, a real folder (no link), owned by this /// account, and closed to everyone else. @@ -79,6 +97,7 @@ pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option { (meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir) } +#[cfg(unix)] /// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime` /// (`unix:path=/bus`, perhaps with a guid after a comma). pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option { @@ -88,6 +107,7 @@ pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option Option { .or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref()))) } +#[cfg(unix)] /// The session bus in `runtime`: the one named in the environment if it /// is there, else its usual socket there, if one exists. fn session_bus(runtime: &Path) -> Option { @@ -115,6 +136,7 @@ fn session_bus(runtime: &Path) -> Option { } } +#[cfg(unix)] /// The ssh agent's socket named in `named`, if it is a socket this /// account owns at an absolute path (INVARIANT 1). fn own_agent(named: Option<&std::ffi::OsStr>) -> Option { @@ -126,6 +148,7 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option { .then(|| path.as_os_str().to_os_string()) } +#[cfg(unix)] /// The host's environment (INVARIANT 1), given the opener's /// `SSH_AUTH_SOCK`. fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> { @@ -145,6 +168,7 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st env } +#[cfg(unix)] /// Whether this process sits in a unit under this account's user manager /// (an app's scope or service, as uwsm makes for every app), whose stop /// would take the host with it; going by the unified cgroup line of @@ -160,6 +184,7 @@ pub fn in_app_scope(cgroup: &str, uid: u32) -> bool { }) } +#[cfg(unix)] /// systemd-run, if this process is in an app scope and the user manager /// can be reached (INVARIANT 3). fn systemd_run() -> Option<&'static str> { @@ -173,6 +198,7 @@ fn systemd_run() -> Option<&'static str> { SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file()) } +#[cfg(unix)] /// Start a host for the root (`root`, else the one the host finds /// itself), with these knobs and these server flags (INVARIANTS 1-5). pub fn start_host( @@ -266,9 +292,11 @@ pub fn start_host( Ok(()) } +#[cfg(unix)] /// How long systemd-run has to make its scope and run the host. const SCOPE_WAIT: Duration = Duration::from_secs(2); +#[cfg(unix)] /// Whether systemd-run made its scope and ran the host (INVARIANT 3): the /// host's first line is in its log after `from`, or systemd-run is still /// running when SCOPE_WAIT is up (it became the host). Only systemd-run @@ -298,6 +326,111 @@ fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) - } } +/// The host's environment on Windows (INVARIANT 1's counterpart): the +/// system's own variables, the account's profile, and a PATH fixed to the +/// system folders and Git for Windows (the backups run `git`). Nothing +/// else of the opener's passes. (CHOICE, P11c: no authority states a +/// Windows list; this is the least a Windows program and `git` need.) +#[cfg(windows)] +fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> { + let var = |name: &str| std::env::var_os(name); + let system_root: PathBuf = var("SystemRoot") + .or_else(|| var("windir")) + .map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + let program_files: PathBuf = + var("ProgramFiles").map_or_else(|| PathBuf::from(r"C:\Program Files"), PathBuf::from); + let path = std::env::join_paths([ + system_root.join("System32"), + system_root.clone(), + program_files.join("Git").join("cmd"), + ]) + .unwrap_or_default(); + let mut env: Vec<(String, std::ffi::OsString)> = vec![ + ("PATH".into(), path), + ("SystemRoot".into(), system_root.clone().into_os_string()), + ("windir".into(), system_root.into_os_string()), + ("USERPROFILE".into(), home.as_os_str().to_os_string()), + ("HOME".into(), home.as_os_str().to_os_string()), + ("TEMP".into(), std::env::temp_dir().into_os_string()), + ("TMP".into(), std::env::temp_dir().into_os_string()), + ]; + if let Some(local) = ledger_store::winsec::local_app_data() { + env.push(("LOCALAPPDATA".into(), local.into_os_string())); + } + for name in ["APPDATA", "ComSpec", "PATHEXT", "ProgramFiles"] { + if let Some(value) = var(name) { + env.push((name.into(), value)); + } + } + env +} + +/// Start a host for the root (`root`, else the one the host finds +/// itself), with these knobs and these server flags (INVARIANTS 1, 2, 4 +/// and 5; there is no scope to leave, but a job to break away from). +#[cfg(windows)] +pub fn start_host( + root: Option<&Path>, + knobs: &Knobs, + server_flags: &[String], +) -> Result<(), String> { + use std::os::windows::process::CommandExt; + let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; + let run = found.run_dir().map_err(|e| e.to_string())?; + let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + let exe = this_executable()?; + let mut args: Vec = vec!["host".into()]; + if root.is_some() { + args.push("--root".into()); + args.push(found.path().as_os_str().to_os_string()); + } + args.extend(knobs.args().into_iter().map(Into::into)); + args.extend(server_flags.iter().map(Into::into)); + let log = || { + let path = run.join(HOST_LOG); + if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT) + && !crate::opener::host_lock_held(&run) + { + let _ = std::fs::rename(&path, path.with_extension("log.1")); + } + std::fs::OpenOptions::new() + .create(true) + .read(true) + .append(true) + .open(&path) + .map_err(|e| format!("can't open the host's log: {}", e.kind())) + }; + let spawn = |flags: u32| -> Result { + Command::new(&exe) + .args(&args) + .env_clear() + .envs(host_env(&home)) + .current_dir(&run) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log()?) + .creation_flags(flags) + .spawn() + .map_err(|e| { + // Named for the caller below: breakaway refused. + if e.kind() == std::io::ErrorKind::PermissionDenied { + "denied".to_string() + } else { + format!("can't start the books' host: {e}") + } + }) + }; + let child = match spawn(DETACHED_HOST | BREAKAWAY) { + Ok(child) => child, + // The job the opener runs in doesn't let a process leave it: the + // host starts inside it, as a host without a scope does on Linux. + Err(why) if why == "denied" => spawn(DETACHED_HOST)?, + Err(why) => return Err(why), + }; + reap(child); + Ok(()) +} + /// Reap a started host when it ends, without waiting for it (INVARIANT 5). fn reap(mut child: std::process::Child) { let _ = std::thread::Builder::new() @@ -307,7 +440,7 @@ fn reap(mut child: std::process::Child) { }); } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; @@ -457,3 +590,44 @@ mod tests { assert!(exe.is_file(), "{exe:?}"); } } + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + + #[test] + fn this_executable_is_a_file_on_disk() { + let exe = this_executable().unwrap(); + assert!(exe.is_file(), "{exe:?}"); + } + + #[test] + fn the_host_environment_carries_nothing_of_the_openers_choosing() { + let env = host_env(Path::new(r"C:\Users\someone")); + let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect(); + for n in &names { + assert!( + [ + "PATH", + "SystemRoot", + "windir", + "USERPROFILE", + "HOME", + "TEMP", + "TMP", + "LOCALAPPDATA", + "APPDATA", + "ComSpec", + "PATHEXT", + "ProgramFiles" + ] + .contains(n), + "{n}" + ); + } + let home = env.iter().find(|(n, _)| n == "HOME").unwrap(); + assert_eq!(home.1, r"C:\Users\someone"); + let path = env.iter().find(|(n, _)| n == "PATH").unwrap(); + assert!(path.1.to_string_lossy().to_lowercase().contains("system32")); + } +} diff --git a/crates/ledger-host/src/tokens.rs b/crates/ledger-host/src/tokens.rs index 6bb90c4..b18cd51 100644 --- a/crates/ledger-host/src/tokens.rs +++ b/crates/ledger-host/src/tokens.rs @@ -125,6 +125,9 @@ impl Keeper { std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700)) .map_err(refused)?; } + // Windows: this account's alone, and what is made in it too. + #[cfg(windows)] + ledger_store::winsec::set_owner_only(&self.0).map_err(refused)?; let name = account.file_name(); let tmp = self.0.join(format!("{name}.tmp{}", std::process::id())); let _ = std::fs::remove_file(&tmp); diff --git a/crates/ledger-host/tests/windows_pipe.rs b/crates/ledger-host/tests/windows_pipe.rs new file mode 100644 index 0000000..31abf29 --- /dev/null +++ b/crates/ledger-host/tests/windows_pipe.rs @@ -0,0 +1,53 @@ +//! The books' named pipes on Windows (P11c, D-62, plan 3.9): what keeps a +//! second account out of them, in the two ways the design has (the pipe's +//! DACL, and the SID check of the process at the other end). Windows job +//! only. +#![cfg(windows)] + +use ledger_mcp::ipc::{Endpoint, Listener}; +use ledger_store::winsec; +use std::path::Path; + +/// The process id of `image` (a system program), if `tasklist` finds it. +fn pid_of(image: &str) -> Option { + let out = std::process::Command::new("tasklist") + .args(["/FI", &format!("IMAGENAME eq {image}"), "/FO", "CSV", "/NH"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + // "services.exe","700","Services","0","12,345 K" + text.lines() + .find(|l| l.to_lowercase().contains(&image.to_lowercase()))? + .split(',') + .nth(1)? + .trim_matches('"') + .parse() + .ok() +} + +#[tokio::test] +async fn a_second_user_cannot_open_the_pipe() { + let dir = tempfile::tempdir().unwrap(); + let endpoint = Endpoint::new(dir.path(), "lease.sock").unwrap(); + let _listener = Listener::bind(&endpoint).unwrap(); + + // The pipe's own DACL names this account and no one else: Everyone, + // Users and Authenticated Users are not on it, so the system itself + // refuses another account's open. + let pipe = Path::new(endpoint.pipe_name()); + assert!( + winsec::is_private(pipe).unwrap(), + "the pipe is open to more than this account" + ); + + // And a process of another account is never taken for this one: this + // account's own is, and a system service (SYSTEM) is not. + assert!(winsec::pid_is_us(std::process::id())); + if let Some(pid) = pid_of("services.exe") { + assert!( + !winsec::pid_is_us(pid), + "a SYSTEM process ({pid}) was taken for this account" + ); + } + assert!(!winsec::pid_is_us(0)); +} diff --git a/crates/ledger-mcp/src/admin.rs b/crates/ledger-mcp/src/admin.rs index a5cbe12..696db43 100644 --- a/crates/ledger-mcp/src/admin.rs +++ b/crates/ledger-mcp/src/admin.rs @@ -521,14 +521,12 @@ impl std::fmt::Display for CallError { /// Ask the server on `socket` (the root's `run/admin.sock`), waiting as /// long as it takes (a create or an adoption). -#[cfg(unix)] pub fn call(socket: &std::path::Path, request: Request) -> Result { call_within(socket, request, None) } /// [`call`], waiting at most `wait` for the answer (a quick look, such /// as `doctor`'s). -#[cfg(unix)] pub fn call_within( socket: &std::path::Path, request: Request, @@ -574,15 +572,14 @@ pub fn call_within( .and_then(|n| n.to_str()) .unwrap_or_default(), ); - let short = ledger_store::root::SocketPath::new(dir, name) + let endpoint = crate::ipc::Endpoint::new(dir, name) .map_err(|e| not_sent(format!("can't reach the books server: {e}")))?; - let stream = - std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { - not_sent("the books server isn't running".to_string()) - } - _ => not_sent(format!("can't reach the books server: {}", e.kind())), - })?; + let stream = crate::ipc::connect_std(&endpoint).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { + not_sent("the books server isn't running".to_string()) + } + _ => not_sent(format!("can't reach the books server: {}", e.kind())), + })?; crate::local::check_peer_std(&stream).map_err(not_sent)?; stream .set_read_timeout(wait) @@ -896,26 +893,38 @@ mod tests { ); } - /// A fake books server on the socket `name` in `dir`: it takes one + /// A fake books server on the channel `name` in `dir`: it takes one /// request and answers `answer`, byte for byte. - #[cfg(unix)] fn answering( dir: &std::path::Path, name: &str, answer: Vec, ) -> (std::path::PathBuf, std::thread::JoinHandle<()>) { let socket = dir.join(name); - let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let endpoint = crate::ipc::Endpoint::new(dir, name).unwrap(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - use std::io::{BufRead, Write}; - let (stream, _) = listener.accept().unwrap(); - let mut request = String::new(); - std::io::BufReader::new(&stream) - .read_line(&mut request) + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .unwrap(); - // A client that stops reading part way breaks the pipe: fine. - let _ = (&stream).write_all(&answer); + rt.block_on(async move { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let mut listener = crate::ipc::Listener::bind(&endpoint).unwrap(); + ready_tx.send(()).unwrap(); + let stream = listener.accept().await.unwrap(); + let (read, mut write) = crate::ipc::split_accepted(stream); + let mut request = String::new(); + tokio::io::BufReader::new(read) + .read_line(&mut request) + .await + .unwrap(); + // A client that stops reading part way breaks the pipe: fine. + let _ = write.write_all(&answer).await; + let _ = write.flush().await; + }); }); + ready_rx.recv().unwrap(); (socket, server) } @@ -931,7 +940,6 @@ mod tests { /// newline not counted, is taken whole (settings texts ride in them); /// a byte more is refused, as is an answer cut off before its /// newline, even when what came parses. - #[cfg(unix)] #[test] fn an_answer_is_one_line_of_at_most_four_mib() { let four_mib = 4 * 1024 * 1024; @@ -956,7 +964,6 @@ mod tests { /// No server on the socket: the request never left, and the command /// line says the server isn't running, whether there is no socket or /// one a stopped server left behind. - #[cfg(unix)] #[test] fn no_server_means_nothing_was_sent() { let dir = tempfile::tempdir().unwrap(); @@ -965,17 +972,21 @@ mod tests { call(&dir.path().join("admin.sock"), Request::List), not_running ); - let stale = dir.path().join("stale.sock"); - drop(std::os::unix::net::UnixListener::bind(&stale).unwrap()); - assert_eq!( - call( - &stale, - Request::Create { - id: "acme".into(), - currency: "USD".into(), - } - ), - not_running - ); + // (A pipe has no stale file to leave behind: only a socket does.) + #[cfg(unix)] + { + let stale = dir.path().join("stale.sock"); + drop(std::os::unix::net::UnixListener::bind(&stale).unwrap()); + assert_eq!( + call( + &stale, + Request::Create { + id: "acme".into(), + currency: "USD".into(), + } + ), + not_running + ); + } } } diff --git a/crates/ledger-mcp/src/ipc.rs b/crates/ledger-mcp/src/ipc.rs new file mode 100644 index 0000000..6e589d4 --- /dev/null +++ b/crates/ledger-mcp/src/ipc.rs @@ -0,0 +1,793 @@ +//! The local transport, one seam for both families of system (plan 3.9, +//! D-62): Unix sockets in the root's `run/` on Unix, named pipes on +//! Windows, each reachable by this account alone. +//! +//! ```text +//! GOAL: the books server, the host and everything that talks to them +//! meet on a channel that only this account can open, and each end +//! checks that the other end is this account. +//! SOURCE: plan/served.md 3.9 ("the local transport, the peer check": +//! 0600 sockets plus the peer's uid; on Windows a pipe with an +//! owner-only DACL plus the peer process's SID), D-32 (nothing +//! here confines a process), D-62. +//! INVARIANTS: +//! 1. A listener is made for this account alone: Unix sockets 0600; a +//! Windows pipe with a DACL of one allow entry (this account's SID) +//! and remote clients refused. +//! 2. [`Peer::peer_is_us`] is true only when the system says the other +//! end is this account (the effective uid; the user SID of the +//! process at the other end of the pipe); any doubt is false. +//! 3. A Windows pipe's name carries a hash of the root's `run/` folder, +//! so two roots on one machine never share a pipe, and the first +//! instance can be made only once (a second server is refused). +//! 4. Windows keeps idle pipe instances waiting, so a client never +//! finds every instance busy while the server makes the next one. +//! 5. A read with a time limit gives up at the limit with `TimedOut` +//! (Unix: `WouldBlock`); a pipe whose other end has gone reads as +//! the end of the stream. +//! ``` + +use std::io; + +pub use sys::*; + +/// Who is at the other end of a connection. +pub trait Peer { + /// Whether the other end is this account (INVARIANT 2). + fn peer_is_us(&self) -> bool; + /// The other end's process, where the system says it. + fn peer_pid(&self) -> Option; +} + +/// Whether the other end of `stream` is this account. +pub fn peer_is_us(stream: &impl Peer) -> bool { + stream.peer_is_us() +} + +/// The other end's process, where the system says. +pub fn peer_pid(stream: &impl Peer) -> Option { + stream.peer_pid() +} + +/// One of the signals that ask a program to stop: on Unix SIGTERM, +/// SIGINT and SIGHUP; on Windows Ctrl+C, Ctrl+Break, closing the console +/// and shutdown. +pub struct StopSignal(sys::RawStop); + +impl StopSignal { + /// The next time it arrives; `None` once it can't arrive again. + pub async fn recv(&mut self) -> Option<()> { + self.0.recv().await + } +} + +/// Watch for every signal that asks a program to stop. +pub fn stop_signals() -> io::Result> { + sys::raw_stops().map(|all| all.into_iter().map(StopSignal).collect()) +} + +#[cfg(unix)] +mod sys { + use super::Peer; + use std::io; + use std::os::fd::AsRawFd; + use std::path::{Path, PathBuf}; + + /// A connection a listener accepted. + pub type Accepted = tokio::net::UnixStream; + /// A connection this side made. + pub type Connected = tokio::net::UnixStream; + /// A connection this side made, for blocking code. + pub type StdStream = std::os::unix::net::UnixStream; + /// What [`Listener::addr_of`] says of a connection. + pub type Addr = tokio::net::unix::SocketAddr; + /// The reading half of an accepted connection. + pub type AcceptedRead = tokio::net::unix::OwnedReadHalf; + /// The writing half of an accepted connection. + pub type AcceptedWrite = tokio::net::unix::OwnedWriteHalf; + /// The reading half of a connection this side made. + pub type ConnectedRead = tokio::net::unix::OwnedReadHalf; + /// The writing half of a connection this side made. + pub type ConnectedWrite = tokio::net::unix::OwnedWriteHalf; + + /// Split an accepted connection into its halves. + pub fn split_accepted(stream: Accepted) -> (AcceptedRead, AcceptedWrite) { + stream.into_split() + } + + /// Split a connection this side made into its halves. + pub fn split_connected(stream: Connected) -> (ConnectedRead, ConnectedWrite) { + stream.into_split() + } + + /// Where a channel is: a socket `name` in the root's `run/`. + #[derive(Debug)] + pub struct Endpoint { + full: PathBuf, + short: ledger_store::root::SocketPath, + } + + impl Endpoint { + /// The channel `name` in the root's `run/` folder `dir`. + pub fn new(dir: &Path, name: &str) -> io::Result { + Ok(Endpoint { + full: dir.join(name), + short: ledger_store::root::SocketPath::new(dir, name)?, + }) + } + + /// The path to bind or connect to (good while this lives). + pub fn path(&self) -> &Path { + self.short.path() + } + } + + /// A listener that is this account's alone (INVARIANT 1). + pub struct Listener(tokio::net::UnixListener); + + impl Listener { + /// Bind `endpoint`, replacing a stale socket (only a server that + /// holds its lock gets here). + pub fn bind(endpoint: &Endpoint) -> io::Result { + match std::fs::remove_file(&endpoint.full) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let listener = tokio::net::UnixListener::bind(endpoint.path())?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&endpoint.full, std::fs::Permissions::from_mode(0o600))?; + Ok(Listener(listener)) + } + + /// The next connection, whoever it is from. + pub async fn accept(&mut self) -> io::Result { + self.0.accept().await.map(|(stream, _)| stream) + } + + /// The address of an accepted connection. + pub fn addr_of(stream: &Accepted) -> io::Result { + stream.peer_addr() + } + + /// The address this listener is bound to. + pub fn local_addr(&self) -> io::Result { + self.0.local_addr() + } + } + + /// Connect to `endpoint`. + pub async fn connect(endpoint: &Endpoint) -> io::Result { + tokio::net::UnixStream::connect(endpoint.path()).await + } + + /// Connect to `endpoint`, for blocking code. + pub fn connect_std(endpoint: &Endpoint) -> io::Result { + std::os::unix::net::UnixStream::connect(endpoint.path()) + } + + /// The user ID at the other end of a Unix socket. + pub fn peer_uid(fd: &impl AsRawFd) -> io::Result { + let fd = fd.as_raw_fd(); + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut cred = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `cred` and `len` are valid for writes of the sizes given. + let rc = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + if len as usize != std::mem::size_of::() { + return Err(io::Error::other("the peer's credentials came back short")); + } + Ok(cred.uid) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + // SAFETY: `uid` and `gid` are valid for writes. + if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(uid) + } + } + + /// This process's user ID. + pub fn own_uid() -> u32 { + // SAFETY: geteuid has no preconditions and can't fail. + unsafe { libc::geteuid() } + } + + fn pid_of(fd: &impl AsRawFd) -> Option { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let mut cred = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: `cred` and `len` are valid for writes of the sizes given. + let rc = unsafe { + libc::getsockopt( + fd.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + (rc == 0 && len as usize == std::mem::size_of::() && cred.pid > 0) + .then_some(cred.pid as u32) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let _ = fd; + None + } + } + + impl Peer for T { + fn peer_is_us(&self) -> bool { + peer_uid(self).is_ok_and(|uid| uid == own_uid()) + } + + fn peer_pid(&self) -> Option { + pid_of(self) + } + } + + /// One stop signal. + pub struct RawStop(tokio::signal::unix::Signal); + + impl RawStop { + pub(super) async fn recv(&mut self) -> Option<()> { + self.0.recv().await + } + } + + pub(super) fn raw_stops() -> io::Result> { + use tokio::signal::unix::{SignalKind, signal}; + [ + SignalKind::terminate(), + SignalKind::interrupt(), + SignalKind::hangup(), + ] + .into_iter() + .map(|kind| signal(kind).map(RawStop)) + .collect() + } +} + +#[cfg(windows)] +mod sys { + use super::Peer; + use ledger_store::winsec; + use std::io; + use std::os::windows::fs::OpenOptionsExt; + use std::path::Path; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + use tokio::net::windows::named_pipe::{ + ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions, + }; + use tokio::sync::mpsc; + + /// ERROR_PIPE_BUSY: every instance of the pipe is in use for now. + const PIPE_BUSY: i32 = 231; + /// ERROR_BROKEN_PIPE: the other end has gone. + const BROKEN_PIPE: i32 = 109; + /// SECURITY_IDENTIFICATION: the server may learn who the client is + /// and may not act as the client. + const IDENTIFICATION: u32 = 0x0001_0000; + /// How long a client waits for a busy pipe (INVARIANT 4). + const BUSY_WAIT: Duration = Duration::from_secs(3); + /// Instances kept waiting for a client (INVARIANT 4). + const IDLE_INSTANCES: usize = 16; + + /// A connection a listener accepted. + pub type Accepted = NamedPipeServer; + /// A connection this side made. + pub type Connected = NamedPipeClient; + /// What [`Listener::addr_of`] says of a connection: nothing. + pub type Addr = (); + /// The reading half of an accepted connection. + pub type AcceptedRead = tokio::io::ReadHalf; + /// The writing half of an accepted connection. + pub type AcceptedWrite = tokio::io::WriteHalf; + /// The reading half of a connection this side made. + pub type ConnectedRead = tokio::io::ReadHalf; + /// The writing half of a connection this side made. + pub type ConnectedWrite = tokio::io::WriteHalf; + + /// Split an accepted connection into its halves. + pub fn split_accepted(stream: Accepted) -> (AcceptedRead, AcceptedWrite) { + tokio::io::split(stream) + } + + /// Split a connection this side made into its halves. + pub fn split_connected(stream: Connected) -> (ConnectedRead, ConnectedWrite) { + tokio::io::split(stream) + } + + /// Where a channel is: the pipe `\\.\pipe\wanderbooks--` (INVARIANT 3). + #[derive(Debug, Clone)] + pub struct Endpoint { + name: String, + } + + impl Endpoint { + /// The channel `name` for the root's `run/` folder `dir`. + pub fn new(dir: &Path, name: &str) -> io::Result { + use sha2::{Digest, Sha256}; + // The folder as the system names it (a short 8.3 name and its + // long one are one folder; every process must hash the same + // text), without the `\\?\` prefix canonicalizing adds; a + // folder name is not case-sensitive here. + let dir = std::fs::canonicalize(dir).or_else(|_| std::path::absolute(dir))?; + let text = dir.to_string_lossy().to_lowercase(); + let key = text.strip_prefix(r"\\?\").unwrap_or(&text); + let digest = Sha256::digest(key.as_bytes()); + let hash: String = digest[..8].iter().map(|b| format!("{b:02x}")).collect(); + let name = name.strip_suffix(".sock").unwrap_or(name); + Ok(Endpoint { + name: format!(r"\\.\pipe\wanderbooks-{hash}-{name}"), + }) + } + + /// The pipe's name. + pub fn pipe_name(&self) -> &str { + &self.name + } + } + + fn make(name: &str, security: &winsec::OwnerOnly, first: bool) -> io::Result { + let mut options = ServerOptions::new(); + options + .first_pipe_instance(first) + .reject_remote_clients(true); + // SAFETY: the pointer is to security attributes that `security` + // keeps alive for the call, which copies them. + unsafe { options.create_with_security_attributes_raw(name, security.as_raw()) } + } + + /// A listener that is this account's alone (INVARIANT 1): a pool of + /// idle instances (INVARIANT 4), each handing its connection on when + /// a client comes. + pub struct Listener { + accepted: mpsc::UnboundedReceiver>, + tasks: Vec>, + } + + impl Drop for Listener { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } + } + + impl Listener { + /// Make the pipe. Refused when another server already has it + /// (INVARIANT 3). + pub fn bind(endpoint: &Endpoint) -> io::Result { + let security = Arc::new(winsec::OwnerOnly::new()?); + let first = make(&endpoint.name, &security, true)?; + let (tx, accepted) = mpsc::unbounded_channel(); + let mut tasks = Vec::new(); + let mut instance = Some(first); + for _ in 0..IDLE_INSTANCES { + let server = match instance.take() { + Some(server) => server, + None => make(&endpoint.name, &security, false)?, + }; + tasks.push(tokio::spawn(wait_for_clients( + server, + endpoint.name.clone(), + security.clone(), + tx.clone(), + ))); + } + Ok(Listener { accepted, tasks }) + } + + /// The next connection, whoever it is from. + pub async fn accept(&mut self) -> io::Result { + match self.accepted.recv().await { + Some(result) => result, + None => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + + /// The address of an accepted connection: none. + pub fn addr_of(_stream: &Accepted) -> io::Result { + Ok(()) + } + + /// The address this listener is bound to: none. + pub fn local_addr(&self) -> io::Result { + Ok(()) + } + } + + /// One idle instance: wait for a client, put a fresh instance in its + /// place, and hand the connected one on. + async fn wait_for_clients( + mut server: NamedPipeServer, + name: String, + security: Arc, + tx: mpsc::UnboundedSender>, + ) { + loop { + if let Err(e) = server.connect().await { + // This instance is no good: say so, and put a fresh one in + // its place (nothing connected is handed on). + if tx.send(Err(e)).is_err() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + if let Ok(fresh) = make(&name, &security, false) { + server = fresh; + } + continue; + } + let next = loop { + match make(&name, &security, false) { + Ok(next) => break next, + Err(e) => { + if tx.send(Err(e)).is_err() { + return; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + }; + let connected = std::mem::replace(&mut server, next); + if tx.send(Ok(connected)).is_err() { + return; + } + } + } + + /// Connect to `endpoint`, waiting a little while every instance is + /// busy (INVARIANT 4). + pub async fn connect(endpoint: &Endpoint) -> io::Result { + let deadline = Instant::now() + BUSY_WAIT; + loop { + match ClientOptions::new() + .security_qos_flags(IDENTIFICATION) + .open(&endpoint.name) + { + Ok(client) => return Ok(client), + Err(e) if e.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(e) => return Err(e), + } + } + } + + /// A connection this side made, for blocking code. + #[derive(Debug)] + pub struct StdStream { + file: std::fs::File, + read_timeout: Mutex>, + } + + impl StdStream { + /// Give up reading after `timeout` (INVARIANT 5); `None` waits + /// for ever. + pub fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { + *self.read_timeout.lock().unwrap_or_else(|e| e.into_inner()) = timeout; + Ok(()) + } + + fn read_some(&self, buf: &mut [u8]) -> io::Result { + let timeout = *self.read_timeout.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(timeout) = timeout { + // A blocking read can't be cut short, so it is made only + // when something is waiting. + let deadline = Instant::now() + timeout; + loop { + match winsec::pipe_bytes_waiting(&self.file) { + Ok(0) => {} + Ok(_) => break, + Err(e) if e.raw_os_error() == Some(BROKEN_PIPE) => return Ok(0), + Err(e) => return Err(e), + } + if Instant::now() >= deadline { + return Err(io::Error::from(io::ErrorKind::TimedOut)); + } + std::thread::sleep(Duration::from_millis(2)); + } + } + match (&self.file).read(buf) { + Err(e) if e.raw_os_error() == Some(BROKEN_PIPE) => Ok(0), + other => other, + } + } + } + + use std::io::{Read, Write}; + + impl Read for StdStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.read_some(buf) + } + } + + impl Read for &StdStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.read_some(buf) + } + } + + impl Write for StdStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + (&self.file).write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + (&self.file).flush() + } + } + + impl Write for &StdStream { + fn write(&mut self, buf: &[u8]) -> io::Result { + (&self.file).write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + (&self.file).flush() + } + } + + /// Connect to `endpoint`, for blocking code, waiting a little while + /// every instance is busy (INVARIANT 4). + pub fn connect_std(endpoint: &Endpoint) -> io::Result { + let deadline = Instant::now() + BUSY_WAIT; + loop { + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .security_qos_flags(IDENTIFICATION) + .open(&endpoint.name) + { + Ok(file) => { + return Ok(StdStream { + file, + read_timeout: Mutex::new(None), + }); + } + Err(e) if e.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => return Err(e), + } + } + } + + impl Peer for NamedPipeServer { + fn peer_is_us(&self) -> bool { + self.peer_pid().is_some_and(winsec::pid_is_us) + } + + fn peer_pid(&self) -> Option { + winsec::pipe_client_pid(self).ok() + } + } + + impl Peer for NamedPipeClient { + fn peer_is_us(&self) -> bool { + self.peer_pid().is_some_and(winsec::pid_is_us) + } + + fn peer_pid(&self) -> Option { + winsec::pipe_server_pid(self).ok() + } + } + + impl Peer for StdStream { + fn peer_is_us(&self) -> bool { + self.peer_pid().is_some_and(winsec::pid_is_us) + } + + fn peer_pid(&self) -> Option { + winsec::pipe_server_pid(&self.file).ok() + } + } + + /// One stop signal. + pub enum RawStop { + /// Ctrl+C. + CtrlC(tokio::signal::windows::CtrlC), + /// Ctrl+Break. + CtrlBreak(tokio::signal::windows::CtrlBreak), + /// The console window is closed. + CtrlClose(tokio::signal::windows::CtrlClose), + /// The system is shutting down. + CtrlShutdown(tokio::signal::windows::CtrlShutdown), + } + + impl RawStop { + pub(super) async fn recv(&mut self) -> Option<()> { + match self { + RawStop::CtrlC(s) => s.recv().await, + RawStop::CtrlBreak(s) => s.recv().await, + RawStop::CtrlClose(s) => s.recv().await, + RawStop::CtrlShutdown(s) => s.recv().await, + } + } + } + + pub(super) fn raw_stops() -> io::Result> { + use tokio::signal::windows; + Ok(vec![ + RawStop::CtrlC(windows::ctrl_c()?), + RawStop::CtrlBreak(windows::ctrl_break()?), + RawStop::CtrlClose(windows::ctrl_close()?), + RawStop::CtrlShutdown(windows::ctrl_shutdown()?), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + + fn endpoint(dir: &tempfile::TempDir) -> Endpoint { + Endpoint::new(dir.path(), "test.sock").unwrap() + } + + /// Both ends of a connection are this account, and name this process. + #[tokio::test] + async fn a_listener_and_a_client_talk_and_each_knows_the_other_is_us() { + let dir = tempfile::tempdir().unwrap(); + let ep = endpoint(&dir); + let mut listener = Listener::bind(&ep).unwrap(); + let client = connect(&ep).await.unwrap(); + let server = listener.accept().await.unwrap(); + assert!(peer_is_us(&server)); + assert!(peer_is_us(&client)); + #[cfg(any(windows, target_os = "linux"))] + { + assert_eq!(peer_pid(&server), Some(std::process::id())); + assert_eq!(peer_pid(&client), Some(std::process::id())); + } + let (read, mut write) = split_accepted(server); + let (cread, mut cwrite) = split_connected(client); + cwrite.write_all(b"hello\n").await.unwrap(); + let mut line = String::new(); + tokio::io::BufReader::new(read) + .read_line(&mut line) + .await + .unwrap(); + assert_eq!(line, "hello\n"); + write.write_all(b"back\n").await.unwrap(); + let mut line = String::new(); + tokio::io::BufReader::new(cread) + .read_line(&mut line) + .await + .unwrap(); + assert_eq!(line, "back\n"); + } + + /// A blocking client gives up at its limit, and reads the end of the + /// stream once the server has gone (INVARIANT 5). + #[tokio::test] + async fn a_blocking_client_times_out_and_reads_the_end_when_the_server_goes() { + use std::io::Read; + let dir = tempfile::tempdir().unwrap(); + let ep = endpoint(&dir); + let mut listener = Listener::bind(&ep).unwrap(); + let ep2 = ep_for(&dir); + let client = tokio::task::spawn_blocking(move || connect_std(&ep2).unwrap()) + .await + .unwrap(); + let server = listener.accept().await.unwrap(); + assert!(peer_is_us(&client)); + client + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let (client, kind) = tokio::task::spawn_blocking(move || { + let mut byte = [0u8; 1]; + let kind = (&client).read(&mut byte).unwrap_err().kind(); + (client, kind) + }) + .await + .unwrap(); + assert!( + matches!( + kind, + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ), + "{kind:?}" + ); + drop(server); + let n = tokio::task::spawn_blocking(move || { + let mut byte = [0u8; 1]; + (&client).read(&mut byte).unwrap() + }) + .await + .unwrap(); + assert_eq!(n, 0, "the end of the stream"); + } + + fn ep_for(dir: &tempfile::TempDir) -> Endpoint { + Endpoint::new(dir.path(), "test.sock").unwrap() + } + + /// Many clients at once all get in (INVARIANT 4). + #[tokio::test] + async fn many_clients_at_once_all_connect() { + let dir = tempfile::tempdir().unwrap(); + let ep = endpoint(&dir); + let mut listener = Listener::bind(&ep).unwrap(); + let ep = std::sync::Arc::new(ep); + let mut clients = Vec::new(); + for _ in 0..48 { + let ep = ep.clone(); + clients.push(tokio::spawn(async move { connect(&ep).await })); + } + let mut accepted = Vec::new(); + for _ in 0..48 { + accepted.push( + tokio::time::timeout(Duration::from_secs(10), listener.accept()) + .await + .expect("accepted in time") + .unwrap(), + ); + } + for c in clients { + c.await.unwrap().unwrap(); + } + } + + /// Nothing listening: not found, so callers say "not running". + #[tokio::test] + async fn nothing_listening_is_not_found() { + let dir = tempfile::tempdir().unwrap(); + let err = connect(&endpoint(&dir)).await.unwrap_err(); + assert!( + matches!( + err.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ), + "{err:?}" + ); + } + + /// Two roots on one machine never share a pipe (INVARIANT 3), and a + /// second server cannot take the first one's pipe. + #[cfg(windows)] + #[tokio::test] + async fn a_pipe_is_per_root_and_cannot_be_taken_twice() { + let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + assert_ne!(endpoint(&a).pipe_name(), endpoint(&b).pipe_name()); + assert!( + endpoint(&a) + .pipe_name() + .starts_with(r"\\.\pipe\wanderbooks-") + ); + let _first = Listener::bind(&endpoint(&a)).unwrap(); + assert!(Listener::bind(&endpoint(&a)).is_err()); + } +} diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs index 8bff8e7..42f9278 100644 --- a/crates/ledger-mcp/src/lib.rs +++ b/crates/ledger-mcp/src/lib.rs @@ -9,7 +9,7 @@ pub mod access; pub mod admin; pub mod backup; mod handbook; -#[cfg(unix)] +pub mod ipc; pub mod local; pub mod opening; mod policy; diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 4511b2e..abf1016 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -60,6 +60,7 @@ //! ``` use crate::admin::{self, Request, Response}; +use crate::ipc::{self, Peer}; use crate::server::{BooksServer, Isolation, Served, SharedStore}; use ledger_store::Store; use ledger_store::access::{Access, AccessLog, Outcome, Principal}; @@ -69,7 +70,6 @@ use rmcp::transport::streamable_http_server::{ StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, }; use std::collections::BTreeMap; -use std::os::fd::AsRawFd; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -994,110 +994,25 @@ fn moved_in_at_adoption( } } -/// The user ID at the other end of a Unix socket. -pub fn peer_uid(fd: &impl AsRawFd) -> std::io::Result { - let fd = fd.as_raw_fd(); - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let mut cred = libc::ucred { - pid: 0, - uid: 0, - gid: 0, - }; - let mut len = std::mem::size_of::() as libc::socklen_t; - // SAFETY: `cred` and `len` are valid for writes of the sizes given. - let rc = unsafe { - libc::getsockopt( - fd, - libc::SOL_SOCKET, - libc::SO_PEERCRED, - (&mut cred as *mut libc::ucred).cast(), - &mut len, - ) - }; - if rc != 0 { - return Err(std::io::Error::last_os_error()); - } - if len as usize != std::mem::size_of::() { - return Err(std::io::Error::other( - "the peer's credentials came back short", - )); - } - Ok(cred.uid) - } - #[cfg(not(any(target_os = "linux", target_os = "android")))] - { - let mut uid: libc::uid_t = 0; - let mut gid: libc::gid_t = 0; - // SAFETY: `uid` and `gid` are valid for writes. - if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(uid) - } -} - -/// The process at the other end of a Unix socket, where the system says -/// (Linux); `None` elsewhere or if it can't be read. -pub fn peer_pid(fd: &impl AsRawFd) -> Option { - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let mut cred = libc::ucred { - pid: 0, - uid: 0, - gid: 0, - }; - let mut len = std::mem::size_of::() as libc::socklen_t; - // SAFETY: `cred` and `len` are valid for writes of the sizes given. - let rc = unsafe { - libc::getsockopt( - fd.as_raw_fd(), - libc::SOL_SOCKET, - libc::SO_PEERCRED, - (&mut cred as *mut libc::ucred).cast(), - &mut len, - ) - }; - (rc == 0 && len as usize == std::mem::size_of::() && cred.pid > 0) - .then_some(cred.pid as u32) - } - #[cfg(not(any(target_os = "linux", target_os = "android")))] - { - let _ = fd; - None - } -} - -/// This process's user ID. -fn own_uid() -> u32 { - // SAFETY: geteuid has no preconditions and can't fail. - unsafe { libc::geteuid() } -} - -/// Whether the other end of a socket is this account (INVARIANT 2). -pub fn peer_is_us(fd: &impl AsRawFd) -> bool { - peer_uid(fd).is_ok_and(|uid| uid == own_uid()) -} - /// The client's check of the server (plan 3.9: both ends check). -pub(crate) fn check_peer_std(stream: &std::os::unix::net::UnixStream) -> Result<(), String> { - if peer_is_us(stream) { +pub(crate) fn check_peer_std(stream: &ipc::StdStream) -> Result<(), String> { + if stream.peer_is_us() { Ok(()) } else { Err("the books server's socket belongs to another account: not used".into()) } } -/// A Unix listener that hands on only this account's connections +/// A listener that hands on only this account's connections /// (INVARIANT 2). -struct OwnerOnly(tokio::net::UnixListener); +struct OwnerOnly(ipc::Listener); impl OwnerOnly { - async fn next(&mut self) -> tokio::net::UnixStream { + async fn next(&mut self) -> ipc::Accepted { loop { match self.0.accept().await { - Ok((stream, _)) if peer_is_us(&stream) => return stream, - Ok((stream, _)) => { + Ok(stream) if stream.peer_is_us() => return stream, + Ok(stream) => { eprintln!("wanderbooks server: refused a connection from another account"); drop(stream); } @@ -1120,13 +1035,13 @@ impl OwnerOnly { } impl axum::serve::Listener for OwnerOnly { - type Io = tokio::net::UnixStream; - type Addr = tokio::net::unix::SocketAddr; + type Io = ipc::Accepted; + type Addr = ipc::Addr; async fn accept(&mut self) -> (Self::Io, Self::Addr) { loop { let stream = self.next().await; - if let Ok(addr) = stream.peer_addr() { + if let Ok(addr) = ipc::Listener::addr_of(&stream) { return (stream, addr); } } @@ -1818,9 +1733,9 @@ async fn not_found() -> (axum::http::StatusCode, &'static str) { } /// One admin connection: one request, one answer (admin INVARIANT 1). -async fn serve_admin(stream: tokio::net::UnixStream, books: Arc) { +async fn serve_admin(stream: ipc::Accepted, books: Arc) { use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; - let (read, mut write) = stream.into_split(); + let (read, mut write) = ipc::split_accepted(stream); let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1)); let mut line = String::new(); // A request still arriving when closing begins is turned away. @@ -1869,18 +1784,8 @@ async fn serve_admin(stream: tokio::net::UnixStream, books: Arc) { /// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the /// server holding the lock gets here). -fn bind(run: &Path, name: &str) -> std::io::Result { - let path = run.join(name); - match std::fs::remove_file(&path) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - let short = ledger_store::root::SocketPath::new(run, name)?; - let listener = tokio::net::UnixListener::bind(short.path())?; - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - Ok(listener) +fn bind(run: &Path, name: &str) -> std::io::Result { + ipc::Listener::bind(&ipc::Endpoint::new(run, name)?) } /// The address `--http` takes: loopback only (plan 3.9, P9a). @@ -2025,8 +1930,14 @@ pub fn run( } { // Only once it is ours: a refused server touches nothing. - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("can't make the server lock private: {}", e.kind()))?; + } + #[cfg(windows)] + ledger_store::winsec::set_owner_only(&lock_path) .map_err(|e| format!("can't make the server lock private: {}", e.kind()))?; } let books = Arc::new(Books { @@ -2292,7 +2203,6 @@ async fn serve( /// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the /// runtime, before the books are opened. fn watch_for_close(stops: &Stops) -> std::io::Result<()> { - use tokio::signal::unix::{SignalKind, signal}; let on_eof = stops.close.clone(); // A plain thread: a blocked read must never hold up the runtime's end. std::thread::Builder::new() @@ -2311,12 +2221,7 @@ fn watch_for_close(stops: &Stops) -> std::io::Result<()> { } on_eof.cancel(); })?; - for kind in [ - SignalKind::terminate(), - SignalKind::interrupt(), - SignalKind::hangup(), - ] { - let mut signals = signal(kind)?; + for mut signals in ipc::stop_signals()? { let (close, hurry) = (stops.close.clone(), stops.hurry.clone()); tokio::spawn(async move { while signals.recv().await.is_some() { @@ -2613,34 +2518,38 @@ mod tests { assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None); } + #[cfg(unix)] #[test] fn a_socket_pair_is_this_account_at_both_ends() { let (a, b) = std::os::unix::net::UnixStream::pair().unwrap(); - assert_eq!(peer_uid(&a).unwrap(), own_uid()); - assert!(peer_is_us(&b)); + assert_eq!(ipc::peer_uid(&a).unwrap(), ipc::own_uid()); + assert!(ipc::peer_is_us(&b)); assert!(check_peer_std(&a).is_ok()); } + #[cfg(unix)] #[test] fn a_socket_pair_names_this_process() { let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap(); if cfg!(target_os = "linux") { - assert_eq!(peer_pid(&a), Some(std::process::id())); + assert_eq!(ipc::peer_pid(&a), Some(std::process::id())); } let file = std::fs::File::open("/dev/null").unwrap(); - assert_eq!(peer_pid(&file), None); + assert_eq!(ipc::peer_pid(&file), None); } + #[cfg(unix)] #[test] fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() { let file = std::fs::File::open("/dev/null").unwrap(); - assert!(peer_uid(&file).is_err()); - assert!(!peer_is_us(&file)); + assert!(ipc::peer_uid(&file).is_err()); + assert!(!ipc::peer_is_us(&file)); } /// A socket with no other end: the system answers for it (Linux: no /// process, no user), and that answer names neither this process nor /// this account; the client's check refuses it. + #[cfg(unix)] #[test] fn a_socket_with_no_other_end_names_no_process_and_isnt_us() { use std::os::fd::FromRawFd; @@ -2650,8 +2559,8 @@ mod tests { assert!(fd >= 0); // SAFETY: a fresh descriptor of a Unix stream socket, owned by none. let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) }; - assert_eq!(peer_pid(&lone), None); - assert!(!peer_is_us(&lone)); + assert_eq!(ipc::peer_pid(&lone), None); + assert!(!ipc::peer_is_us(&lone)); assert_eq!( check_peer_std(&lone).unwrap_err(), "the books server's socket belongs to another account: not used" @@ -3196,6 +3105,7 @@ mod tests { let id = BookId::parse(id).unwrap(); let dir = books.root.book_dir(&id); std::fs::create_dir(&dir).unwrap(); + #[cfg(unix)] std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700)) .unwrap(); let mut s = diff --git a/crates/ledger-store/src/backup.rs b/crates/ledger-store/src/backup.rs index c2d9b5b..3c3d02f 100644 --- a/crates/ledger-store/src/backup.rs +++ b/crates/ledger-store/src/backup.rs @@ -124,11 +124,20 @@ impl Git { .unwrap_or_default(); dirs.iter() .chain( - ["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"] - .iter() - .map(PathBuf::from) - .collect::>() - .iter(), + [ + "/usr/bin", + "/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + // Git for Windows' own folders (a host started clean + // has a short PATH). + r"C:\Program Files\Git\cmd", + r"C:\Program Files\Git\bin", + ] + .iter() + .map(PathBuf::from) + .collect::>() + .iter(), ) .filter(|d| d.is_absolute()) .map(|d| d.join(if cfg!(windows) { "git.exe" } else { "git" })) @@ -266,8 +275,24 @@ impl Git { let kill_group = |pid: u32| unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL); }; - #[cfg(not(unix))] - let kill_group = |_: u32| {}; + // Windows has no process group to signal: a git that has gone quiet + // is stopped with the tree of processes it started (`taskkill /T`). + // Once git has ended normally nothing is looked for (a process + // spawned per git call would cost more than the leftovers it could + // find). + #[cfg(windows)] + let kill_group = |pid: u32| { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + }; + #[cfg(unix)] + let kill_leftovers = kill_group; + #[cfg(windows)] + let kill_leftovers = |_: u32| {}; let status = loop { if let Some(status) = child.try_wait()? { break status; @@ -284,7 +309,7 @@ impl Git { std::thread::sleep(Duration::from_millis(20)); }; // What it started may still hold its pipes: not waited for long. - kill_group(child.id()); + kill_leftovers(child.id()); let wait = Duration::from_secs(10); let stdout = stdout.recv_timeout(wait).unwrap_or_default(); let stderr = stderr.recv_timeout(wait).unwrap_or_default(); diff --git a/crates/ledger-store/src/winsec.rs b/crates/ledger-store/src/winsec.rs index bcf0638..9f7f629 100644 --- a/crates/ledger-store/src/winsec.rs +++ b/crates/ledger-store/src/winsec.rs @@ -415,11 +415,15 @@ pub fn pid_is_us(pid: u32) -> bool { } /// 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 { +pub fn pipe_client_pid(pipe: &impl std::os::windows::io::AsRawHandle) -> 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 + if unsafe { + windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId( + pipe.as_raw_handle(), + &mut pid, + ) + } == 0 { return Err(io::Error::last_os_error()); } @@ -427,11 +431,15 @@ pub fn pipe_client_pid(handle: std::os::windows::io::RawHandle) -> io::Result io::Result { +pub fn pipe_server_pid(pipe: &impl std::os::windows::io::AsRawHandle) -> 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 + if unsafe { + windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId( + pipe.as_raw_handle(), + &mut pid, + ) + } == 0 { return Err(io::Error::last_os_error()); } @@ -440,13 +448,13 @@ pub fn pipe_server_pid(handle: std::os::windows::io::RawHandle) -> io::Result io::Result { +pub fn pipe_bytes_waiting(pipe: &impl std::os::windows::io::AsRawHandle) -> 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, + pipe.as_raw_handle(), std::ptr::null_mut(), 0, std::ptr::null_mut(), From 1c3a48899d9d6759966f14286d2daf61785ed954 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:02:12 -0600 Subject: [PATCH 03/12] chore: every OS checks out LF (P11c) The Windows job of check.yml gets CRLF from the runner's autocrlf, and the golden, fixture and hash-pinned files are compared byte for byte. No file in the index has CRLF, so nothing changes on Linux or macOS. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..717cc4d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Tests compare text and checked-in hashes byte for byte: every OS checks out LF (the +# Windows job of check.yml would otherwise get CRLF from autocrlf). +* text=auto eol=lf From 2baadb7482846b59e83765bb10058d177f177968 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:02:12 -0600 Subject: [PATCH 04/12] docs: the Windows port, its inventory struck, its test record and the As built block (P11c) packaging.md: each row of the Unix-only inventory is struck with where it landed. architecture.md: the local transport and the Windows arms. testing.md: the P11c record with its For-P12 paragraph (R-G); every Windows test is marked unrun until the first Windows job. README.md: the Windows caveat is now "from a source build, not packaged". served.md: the unit's As built block (field 8). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- README.md | 10 +++++--- docs/architecture.md | 23 ++++++++++++++++++ docs/packaging.md | 43 ++++++++++++++++++++++++-------- docs/testing.md | 58 ++++++++++++++++++++++++++++++++++++++++++++ plan/served.md | 2 +- 5 files changed, 121 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 748343f..e0a0ef1 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,11 @@ Sheet is frozen as of 2026-10-11. The remaining work, its order and its hand-off are `plan/served.md` section 8.0; the destination is one installed app for Mac, Windows and Linux (P11), then the hosted service the plan describes and does not build. Today the agent path runs on -Linux and macOS: tokens live in a 0600 file under the private data root -(D-63); the rest of the host is Unix-only, so Windows is WSL2 only until -its sockets and data root are ported. +Linux, macOS and Windows from a source build: tokens live in a file under +the private data root (D-63), the data root is `%LOCALAPPDATA%\wanderbooks` +with an owner-only access list on Windows (P11c, D-62), and the books' +local channels are named pipes there. Windows is not yet packaged: no +installer, no signing (P11a). See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and [`plan/roadmap.md`](plan/roadmap.md) for where it's going. @@ -88,7 +90,7 @@ them; nothing needs sudo): curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme ``` -(Linux and macOS. On Windows run it inside WSL2.) +(Linux and macOS. On Windows build from source: `cargo install --path crates/ledger-cli`, then the three commands below.) Already installed, three commands: `wanderbooks setup`, `wanderbooks books create acme`, `wanderbooks connect claude --book acme` (or `connect codex`). diff --git a/docs/architecture.md b/docs/architecture.md index a00682d..b2e9c63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -248,6 +248,29 @@ request (its task, or while it holds the guard) sets `stopped`, and every later request, on every session, is refused until a restart, which re-verifies the journal. +## The local transport and the Windows arms (`ledger-mcp::ipc`, `ledger-store::winsec`, P11c, D-62) + +The books server, the host and everything that talks to them meet on +channels only this account can open (plan 3.9). `ledger_mcp::ipc` is the +one seam: `Endpoint` (a socket in the root's `run/` on Unix; the pipe +`\\.\pipe\wanderbooks--` on Windows), `Listener` +(`bind`, `accept`), `connect` and `connect_std` (blocking, with a read +limit), the `Peer` trait (`peer_is_us`: the effective uid on Unix; the +user SID of the process at the other end of the pipe on Windows), and +`stop_signals`. No other file names a socket or a pipe, a peer check or a +signal. HTTP to the server goes through `ledger_host::channel::http_builder` +(reqwest's Unix socket or named pipe). + +`ledger_store::winsec` (Windows only) holds the rest of the Windows FFI: +the owner-only DACL (`set_owner_only`, `is_private`), the account's SID, +the shell's known folders, `MoveFileExW` for the move that must not +replace, and the pipe process ids. `ledger_store::lock` is the one file +lock seam (std's, which is `flock` and `LockFileEx`). `seccomp` and +`close_range` remain Linux-only, doing nothing elsewhere (D-32). Windows +CI is the evidence for every arm that cannot run on the Linux machines +that wrote it; `cargo check --target x86_64-pc-windows-msvc` (the target is +pinned in `mise.toml`) type-checks them, clippy included. + ## bookspec (`bookspec/`, P13) bookspec is the formal accounting specification (D-34): every rule stated diff --git a/docs/packaging.md b/docs/packaging.md index dba150e..a48573e 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -26,16 +26,39 @@ keyring on every OS (tokens are 0600 files under the data root, unit U0e), so nothing about tokens is OS-specific now. The Windows host is a port, not a `cfg` gap. Today Unix-only, found by grep (2026-10-29): -- `ledger-host`: `bridge`, `host`, `start`, `opener` and others are +- ~~`ledger-host`: `bridge`, `host`, `start`, `opener` and others are `#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`, `close_range`, `geteuid`, Unix sockets and `libc` unguarded; - `commands.rs` calls `isatty`/`tcgetpgrp`. -- `ledger-mcp`: the owner-only socket check reads peer credentials + `commands.rs` calls `isatty`/`tcgetpgrp`.~~ Landed (P11c): every module + builds on every OS. The channels are `ledger_mcp::ipc` (below); the host + starts detached (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`, leaving + the opener's job if it lets it) where Unix uses `setsid`, with a + Windows environment list in `start.rs::host_env`; `close_range` and the + `umask` have nothing to do there (a Windows child inherits no handle it + is not given); the terminal checks use `IsTerminal`; the stop signals + are Ctrl+C, Ctrl+Break, console close and shutdown + (`ipc::stop_signals`); a replaced program is told by its size and last + write, since a running program cannot be replaced in place. +- ~~`ledger-mcp`: the owner-only socket check reads peer credentials (`SO_PEERCRED`, `getpeereid`); `admin`/`local` listen on Unix sockets. - Windows needs named pipes with a peer SID check (plan 3.9). -- `ledger-store`: `ops.rs` takes `flock` locks; `root.rs` has no owner-only - DACL (`set_mode` is a no-op off Unix) and the data root falls back to - `%USERPROFILE%`, not the known-folder `%LOCALAPPDATA%`; `refuse.rs` - (seccomp) and `renameat2` are Linux-only (macOS needs its own - equivalents or `cfg` fences). -- The `portable` job runs Windows as allowed-to-fail until that port lands. + Windows needs named pipes with a peer SID check (plan 3.9).~~ Landed + (P11c): `ledger_mcp::ipc` is the one seam. A pipe is + `\\.\pipe\wanderbooks--`, made with + a DACL of one allow entry (this account's SID), remote clients refused, + the first instance made once, and 16 idle instances waiting; each end + checks the other's process (`GetNamedPipeClientProcessId` / + `GetNamedPipeServerProcessId`, then that process's token user) against + its own SID. Clients open with `SECURITY_IDENTIFICATION`. +- ~~`ledger-store`: `ops.rs` takes `flock` locks; `root.rs` has no + owner-only DACL (`set_mode` is a no-op off Unix) and the data root falls + back to `%USERPROFILE%`, not the known-folder `%LOCALAPPDATA%`; + `refuse.rs` (seccomp) and `renameat2` are Linux-only (macOS needs its + own equivalents or `cfg` fences).~~ Landed (P11c): `lock.rs` is the one + lock seam (std's `try_lock`, `LockFileEx` on Windows); `winsec.rs` sets + and reads the owner-only DACL and finds the profile and `LocalAppData` + from the shell's known folders; `rename_new` is `MoveFileExW` without + the replace flag. `refuse.rs` (seccomp, a test fault injector) and the + host's `close_range` stay Linux-only on purpose (D-32: nothing here + confines a process, so nothing is promised where they are absent). + macOS, which has no `renameat2`, still looks before it moves. +- The `portable` job runs Windows as a gate (`experimental: false`), since the port landed (P11c). diff --git a/docs/testing.md b/docs/testing.md index bd944d8..60c6de8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3669,3 +3669,61 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the `zero_net_accounts` branch and the `never_reconciled` split; `report_status`'s three open conditions; the open-drafts refusal's `<= as_of` and its decision; the attention `awaiting_evidence` branch. + +## P11c: the native Windows port (2026-10-29, D-62) + +The Windows arms were written on Linux and type-checked and linted for +`x86_64-pc-windows-msvc` (`cargo check` and `cargo clippy --workspace +--all-targets --target x86_64-pc-windows-msvc -D warnings`, both clean); +they have not run. The `portable (windows-latest)` job, now a gate, is the +evidence, and its first run is the first run of every test marked +"Windows" below. + +- **`ledger-store` `tests/windows_root.rs`** (Windows only): + `the_default_root_sits_under_local_app_data`, + `setup_makes_a_private_root_and_its_folders` (the DACL of the root and of + `books`, `run`, `legacy`), `a_root_others_can_open_is_refused_and_setup_makes_it_private_again` + (Everyone granted with `icacls`, then refused, then tightened), + `what_is_made_in_a_private_folder_is_private` (inheritance), + `a_file_made_owner_only_is_private_and_a_loosened_one_is_not`, + `a_rename_never_goes_over_what_is_there`, + `this_process_runs_as_the_account_that_the_token_says`. +- **`ledger-store` `src/ops.rs`:** `a_viewer_s_presence_is_a_shared_lock_others_can_see` + (every OS; the lock seam). +- **`ledger-mcp` `src/ipc.rs`** (every OS): + `a_listener_and_a_client_talk_and_each_knows_the_other_is_us`, + `a_blocking_client_times_out_and_reads_the_end_when_the_server_goes`, + `many_clients_at_once_all_connect` (48 at once: the idle pool), + `nothing_listening_is_not_found`; Windows only: + `a_pipe_is_per_root_and_cannot_be_taken_twice`. + `admin.rs`'s fake-server tests (`an_answer_is_one_line_of_at_most_four_mib`, + `no_server_means_nothing_was_sent`) now run over `ipc` on every OS. +- **`ledger-host` `tests/windows_pipe.rs`:** `a_second_user_cannot_open_the_pipe` + (Windows only). A second account cannot be made on the runner, so it + checks the two things that keep one out: the pipe's DACL names this + account alone, and a SYSTEM process (`services.exe`) is not taken for + this account by the SID check. +- **`ledger-host`:** `start.rs` `windows_tests`, `host.rs` + `a_folder_the_host_can_write_isnt_a_sandbox` and + `only_a_file_has_an_identity_and_a_replaced_one_another` (Windows + variants of the Unix tests of the same names). +- **The over-the-wire suite** (`ledger-cli` `tests/common/mod.rs`) now + runs on Windows: `Home` finds its root by `default_root`, children get + `clean_env`, `mcp_http` reaches the server over its pipe, folders are + made private by `private_folder`. Tests that signal processes + (`libc::kill`), use Unix sockets, modes, symlinks or `/proc` stay + `cfg(unix)` / `cfg(target_os = "linux")`. `one_executable.rs`'s + `without_local_sockets_the_modes_that_hold_the_books_say_so` is gone (the + modes work); `the_books_server_needs_a_root` runs everywhere. + `ledger-gui` `read_only` marks files read-only (not folders) on Windows. + +**For P12 (mutation targets, none run, D-33):** `winsec::is_private`'s +three refusals (owner, missing DACL, an allow entry for anyone else) and the +ACE types it skips; `ipc::Endpoint::new`'s hash input (a mutant that drops +the lower-casing or the prefix strip splits one root into two pipes: found +only by two processes spelling the path differently); the Windows +`StdStream::read_some`'s deadline comparison and its `BROKEN_PIPE` +arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant that makes it 1 passes +every test but `many_clients_at_once_all_connect`); `start.rs::host_env`'s +list. None of the Windows arms is reachable by `cargo mutants` on Linux: +they run on the Windows job only. diff --git a/plan/served.md b/plan/served.md index 5124f44..bdaa07a 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1667,7 +1667,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns the Windows arms of the files above; touches `docs/packaging.md` (the inventory rows are struck as each lands), `docs/architecture.md`, `docs/testing.md`, `README.md` (the Windows caveat goes). 6. **Tests and proofs:** the existing root, lock, lease and transport tests run on the Windows job (`cargo test --workspace` there); `a_second_user_cannot_open_the_pipe` (new, Windows-only, `crates/ledger-host`); no kernel, Kani or Lean change. 7. **Done when:** (1) the `portable (windows-latest)` job is green with `experimental: false`; (2) `wanderbooks setup`, `books create`, `connect claude`, a bridge session, an upload and `wanderbooks gui` work on Windows from a source build (the engineer or the owner on a Windows machine; recorded in the As-built block); (3) `docs/packaging.md`'s Unix-only inventory is empty. -8. **As built:** none yet. +8. **As built:** (2026-10-29, branch `P11c`; written on Linux, type-checked and linted for `x86_64-pc-windows-msvc` with the target pinned in `mise.toml`, **not run on Windows**: the `portable (windows-latest)` job's first run is the evidence for everything marked unverified here, and Done-when 2 needs a person on a Windows machine.) **Step 1, the data root:** `ledger-store/src/winsec.rs (new, Windows only)` holds the Windows FFI (dependency `windows-sys` 0.61, `cfg(windows)`): `set_owner_only` (a protected DACL of one allow entry for the account's SID, inherited by what a folder holds), `is_private` (owner is the account or Administrators; no allow entry for anyone but the account, SYSTEM or Administrators; a missing DACL is not private; any failure is "no"), `current_user`/`process_user`/`pid_is_us`, the shell's known folders (`profile_folder`, `local_app_data`), `rename_new` (`MoveFileExW` without the replace flag, write-through) and the pipe process-id calls. `root.rs`: `account_home` is the profile known folder; `platform_data_dir(home)` is the shell's `LocalAppData` for the account's own home and `home\AppData\Local` for any other (tests); `set_mode`, `check_private_dir` (now takes the path), `recorded_root` and `rename_new` have their Windows arms beside the Unix ones. **Step 2, locking:** `ledger-store/src/lock.rs (new)` is the one seam, on std's `try_lock`/`try_lock_shared` (flock; `LockFileEx`), replacing the only `libc::flock` (`ops.rs`); the plan's "LockFileEx behind lock.rs" is std's, so there is no second `unsafe` copy. **Step 3, the transport:** `ledger-mcp/src/ipc.rs (new)` is the seam for both families: `Endpoint`, `Listener`, `connect`, `connect_std` (a blocking client with a read limit; on Windows polled with `PeekNamedPipe`, since a pipe read cannot be cut short), `Peer` (`peer_is_us`, `peer_pid`), `stop_signals`, `split_accepted`/`split_connected`. A Windows pipe is `\\.\pipe\wanderbooks--`, made with an owner-only DACL, remote clients refused, `first_pipe_instance` once, and 16 idle instances kept waiting (each its own task, replaced on connect) so a client never finds all busy; clients open with `SECURITY_IDENTIFICATION`; both ends check the other's process token user against their own SID. `local.rs`, `admin.rs`, `host.rs`, `opener.rs`, `bridge.rs`, `commands.rs` use `ipc` (their `peer_uid`/`peer_pid`/`own_uid` moved into it); `ledger-host/src/channel.rs (new)` gives reqwest the Unix socket or `windows_named_pipe`. `lib.rs` of `ledger-host` and `ledger-mcp` and `main.rs` of `ledger-cli` lost every `cfg(unix)` gate and the "comes with P11" stubs. `start.rs`: the Unix start (`setsid`, `umask`, systemd-run, `XDG_RUNTIME_DIR`) is `cfg(unix)`; the Windows start is `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` (without the last when the job refuses it) with a fixed environment (`host_env`); `host.rs` told a replaced program by (size, last write) on Windows; `close_range` and `seccomp` stay Linux-only (D-32). Also: `backup.rs` stops a quiet git with `taskkill /T` and finds Git for Windows; `tokens.rs` and the viewer's thumbnail folder take the DACL; `.gitattributes` pins LF (autocrlf would break the byte-for-byte golden and hash tests on the runner). **Tests:** see `docs/testing.md` "P11c": `ledger-store/tests/windows_root.rs`, `ledger-mcp/src/ipc.rs` (4 on every OS), `ledger-host/tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants in `start.rs` and `host.rs`; the whole over-the-wire suite of `ledger-cli` now compiles for Windows and runs there through `common::clean_env`/`private_folder`/`default_root`/`mcp_http`. **Checked here:** `cargo check` and `cargo clippy -D warnings` for `x86_64-pc-windows-msvc` over the workspace and all targets (C dependencies stubbed with a no-op compiler/archiver shim, which `check` allows); on Linux fmt, clippy on the crates touched and their tests. **Not verifiable here, so the first CI run decides:** everything that runs on `windows-latest`, in particular the pipe code, the DACL reads, `taskkill`, the host's detached start and the over-the-wire suite. **Answers I chose** (alternatives in the report to the EM): the Windows ancestors check is none (CHOICE); the bridge's scripted-server tests (`against_a_server`) stay `cfg(unix)`; vault and TIN-registry files rely on inheritance from the private folder they sit in. **Left open:** Done-when 2 (a person on Windows runs setup, create, connect, a bridge session, an upload, `gui`), and Done-when 1 until CI shows the job green. #### P12: max testing, again (D-33) From bc82ccbecf9b84ed95632e2d8c45527bed8e73cf Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:09:15 -0600 Subject: [PATCH 05/12] ci: the Windows job gates (experimental: false) (P11c) The port is in; the portable (windows-latest) job no longer hides behind continue-on-error. Its first run is the first run of every Windows arm (see docs/testing.md "P11c"), so the EM should watch that run before merging. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 299ee16..1276013 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -48,7 +48,7 @@ jobs: - os: macos-latest experimental: false - os: windows-latest - experimental: true + experimental: false runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} # Same disk discipline as above: no incremental caches, line tables only. From 4a2b327513a76bcaab61a05dabb9c458471c6fdb Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 18:56:46 -0600 Subject: [PATCH 06/12] test: the servers two_user starts get a Windows environment (P11c) two_user.rs started its flagged servers with env_clear plus a Unix PATH and HOME, no SystemRoot: on Windows Winsock cannot start without it, so the --http door failed to bind (the server says so on stderr and serves its pipes only, which is why "ready" still came). The tests now use the harness's clean_env, as do evidence.rs's extract and the viewer's command_line test. main (a8ce2e7d) merged in. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-cli/tests/evidence.rs | 12 +++++----- crates/ledger-cli/tests/two_user.rs | 30 ++++++++++++------------- crates/ledger-gui/tests/command_line.rs | 7 ++++++ 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/crates/ledger-cli/tests/evidence.rs b/crates/ledger-cli/tests/evidence.rs index 5c7eec7..a05d3cc 100644 --- a/crates/ledger-cli/tests/evidence.rs +++ b/crates/ledger-cli/tests/evidence.rs @@ -439,14 +439,12 @@ fn the_backfill_reads_an_old_fixtures_files_once() { // host does. books.stop(); let run = || { - std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + let mut extract = std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + extract .args(["books", "extract", &books.id, "3", "--root"]) - .arg(books.home.root()) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", books.home.dir.path()) - .output() - .unwrap() + .arg(books.home.root()); + common::clean_env(&mut extract, books.home.dir.path()); + extract.output().unwrap() }; let before = books.journal(); let out = run(); diff --git a/crates/ledger-cli/tests/two_user.rs b/crates/ledger-cli/tests/two_user.rs index c77b5f9..25a9938 100644 --- a/crates/ledger-cli/tests/two_user.rs +++ b/crates/ledger-cli/tests/two_user.rs @@ -136,14 +136,14 @@ impl FlaggedServer { .append(true) .open(home.dir.path().join("server.log")) .unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + server .arg("server") .arg("--root") .arg(home.root()) - .args(args) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", home.dir.path()) + .args(args); + common::clean_env(&mut server, home.dir.path()); + let mut child = server .env("XDG_CONFIG_HOME", home.dir.path().join(".config")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -184,14 +184,14 @@ impl Drop for FlaggedServer { /// Run the server with `args` that should be refused before it serves /// anything: its status, stdout and stderr. fn refused_server(home: &Home, args: &[String]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + server .arg("server") .arg("--root") .arg(home.root()) - .args(args) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", home.dir.path()) + .args(args); + common::clean_env(&mut server, home.dir.path()); + server .env("XDG_CONFIG_HOME", home.dir.path().join(".config")) .output() .unwrap() @@ -240,13 +240,13 @@ fn the_tunnel_door_binds_loopback_only() { } // The same refusal through `serve`, before it starts a host at all. - let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks")) + let mut serve = Command::new(env!("CARGO_BIN_EXE_wanderbooks")); + serve .args(["serve", "--root"]) .arg(home.root()) - .args(["--http", "0.0.0.0:0"]) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("HOME", home.dir.path()) + .args(["--http", "0.0.0.0:0"]); + common::clean_env(&mut serve, home.dir.path()); + let out = serve .env("XDG_CONFIG_HOME", home.dir.path().join(".config")) .output() .unwrap(); diff --git a/crates/ledger-gui/tests/command_line.rs b/crates/ledger-gui/tests/command_line.rs index c285256..9af8167 100644 --- a/crates/ledger-gui/tests/command_line.rs +++ b/crates/ledger-gui/tests/command_line.rs @@ -10,6 +10,13 @@ use std::process::Command; fn viewer(home: &std::path::Path) -> Command { let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui")); c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home); + // A Windows program started with no environment at all can't load. + #[cfg(windows)] + for name in ["SystemRoot", "windir"] { + if let Some(value) = std::env::var_os(name) { + c.env(name, value); + } + } c } From 9f57269def2e9d1707fd39f809fe8bdf8b7b8bd8 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 03:13:47 -0600 Subject: [PATCH 07/12] fix: git gets plain Windows paths; the FIFO test is Unix-only (P11c) std::fs::canonicalize on Windows returns \\?\C:\..., which git refuses ("not a git repository"): every argument handed to git now has the extended-length prefix taken off (root::without_extended_prefix, with a unit test that runs everywhere; \\?\UNC\ becomes \\). The access-record test's fixture is a FIFO in the log's place, which Windows has no counterpart for; the refusal path is the same code on every system, so the test and its helper are Unix-only. main (8f0e5f08) merged in. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-mcp/src/server_tests.rs | 8 +++++-- crates/ledger-store/src/backup.rs | 8 ++++++- crates/ledger-store/src/root.rs | 32 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index af2ab17..108dd4b 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -1159,7 +1159,9 @@ fn logged(tag: &str) -> (BooksServer, TestDir) { } /// Books whose access log can't be written: its file is a FIFO no one -/// reads, so every record fails (as on a full disk). +/// reads, so every record fails (as on a full disk). Unix only: there is +/// no FIFO in a file's place on Windows. +#[cfg(unix)] fn unwritable(tag: &str) -> (BooksServer, TestDir) { let books = unit_dir(tag); let store = store(&books); @@ -1430,7 +1432,9 @@ fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() { /// access.rs INVARIANT 3: a record that can't be written stops the /// books, and the answer says so; a call or read refused as not run /// (closing) keeps its not-run code, so the bridge still sends it -/// again; anything else is an error in words. +/// again; anything else is an error in words. Unix only: its fixture is a +/// FIFO (the refusal path itself is the same on every system). +#[cfg(unix)] #[test] fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() { rt().block_on(async { diff --git a/crates/ledger-store/src/backup.rs b/crates/ledger-store/src/backup.rs index 3c3d02f..6117675 100644 --- a/crates/ledger-store/src/backup.rs +++ b/crates/ledger-store/src/backup.rs @@ -223,7 +223,13 @@ impl Git { fn run(&self, what: &str, args: &[&std::ffi::OsStr]) -> Result { use std::sync::atomic::{AtomicU64, Ordering}; let mut c = self.command(); - c.args(args); + // A path made canonical on Windows carries `\\?\`, which git refuses. + c.args(args.iter().map(|a| match a.to_str() { + Some(text) if text.contains(r"\\?\") => { + std::ffi::OsString::from(crate::root::without_extended_prefix(text)) + } + _ => (*a).to_os_string(), + })); #[cfg(unix)] { use std::os::unix::process::CommandExt; diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 029b342..7d9f151 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -314,6 +314,23 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result String { + text.replace(r"\\?\UNC\", r"\\").replace(r"\\?\", "") +} + +/// [`without_extended_prefix`] for a path (the same path on Unix). +pub fn plain_path(path: &Path) -> PathBuf { + match path.to_str() { + Some(text) if text.contains(r"\\?\") => PathBuf::from(without_extended_prefix(text)), + _ => path.to_path_buf(), + } +} + /// The longest socket path used as it is: a Unix socket's address holds /// 104 bytes on macOS and 108 on Linux, the terminating zero included. const SOCKET_PATH_MAX: usize = 100; @@ -2264,6 +2281,21 @@ fn copy_flat_private(from: &Path, to: &Path) -> Result { mod unit { use super::*; + #[test] + fn the_extended_length_prefix_is_taken_off_paths() { + assert_eq!(without_extended_prefix(r"\\?\C:\a\b"), r"C:\a\b"); + assert_eq!( + without_extended_prefix(r"--git-dir=\\?\C:\a\archive.git"), + r"--git-dir=C:\a\archive.git" + ); + assert_eq!( + without_extended_prefix(r"\\?\UNC\server\share\x"), + r"\\server\share\x" + ); + assert_eq!(without_extended_prefix("/usr/bin/git"), "/usr/bin/git"); + assert_eq!(plain_path(Path::new("/a/b")), Path::new("/a/b")); + } + #[test] fn a_buffer_grows_while_the_lookup_says_too_small() { // Too small twice, then found: 16, 32, 64 KiB tried, in order. From 73804ac8ba7a836b435dc1fe95ca58241e353f3a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 05:58:11 -0600 Subject: [PATCH 08/12] test: the archive check's own git gets a plain Windows path (P11c) local.rs's archive_git handed git the canonical \\?\ path the product's git already no longer sees. Same helper (root::plain_path). The other Command::new("git") tests are Unix-only files (backups.rs, format_upgrade, open_while_held) or use paths from tempfile, not canonicalize. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-mcp/src/local.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 4a59158..56f60a6 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -3171,7 +3171,11 @@ mod tests { /// git, on the books' own archive: what it said. fn archive_git(dir: &Path, args: &[&str]) -> String { let out = std::process::Command::new("git") - .arg(format!("--git-dir={}", dir.join("archive.git").display())) + .arg(format!( + "--git-dir={}", + // A canonical path on Windows carries `\\?\`, which git refuses. + ledger_store::root::plain_path(&dir.join("archive.git")).display() + )) .args(args) .output() .unwrap(); From b96735da01ecd2052b9dcd574ec33b7347e48c60 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 07:49:03 -0600 Subject: [PATCH 09/12] fix(store): truncating an append-only log goes through a write handle on Windows (P11c) std's append(true) opens without FILE_WRITE_DATA, so SetEndOfFile on that handle is refused (PermissionDenied) on Windows: the torn-line cut on open (chain::tests::an_unfinished_last_line_is_cut_away_on_open) and the roll-back of a failed append (chain and journal) could not truncate. Not a rename/share-delete problem: std already opens with all three sharing flags. chain::truncate cuts through a short-lived write handle on Windows and the handle itself elsewhere; the journal's roll-back uses it too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/src/chain.rs | 28 ++++++++++++++++++++++++---- crates/ledger-store/src/journal.rs | 3 +-- docs/packaging.md | 8 ++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/ledger-store/src/chain.rs b/crates/ledger-store/src/chain.rs index 8c87381..b7ce5fb 100644 --- a/crates/ledger-store/src/chain.rs +++ b/crates/ledger-store/src/chain.rs @@ -211,8 +211,7 @@ impl ChainLog { crate::winsec::set_owner_only(path)?; } if read.torn { - file.set_len(read.good_len)?; - file.sync_all()?; + truncate(&file, path, read.good_len)?; } // A log just made is in its folder for good (its entry synced). #[cfg(unix)] @@ -280,8 +279,7 @@ impl ChainLog { .and_then(|()| self.file.sync_data()); if let Err(e) = written { self.broken = true; - let _ = self.file.set_len(before); - let _ = self.file.sync_all(); + let _ = truncate(&self.file, &self.path, before); return Err(e.into()); } self.head = Head { seq, hash }; @@ -289,6 +287,28 @@ impl ChainLog { } } +/// Cut the log `path` (open as `file`) to `len` bytes and sync it. On +/// Windows an append-only handle (which is what `append(true)` opens) has +/// no right to truncate, so a short-lived handle with write access does it; +/// the log is opened with the sharing std gives every handle (read, write +/// and delete), so both can be open at once. +pub(crate) fn truncate(file: &File, path: &Path, len: u64) -> std::io::Result<()> { + #[cfg(windows)] + { + let cutter = OpenOptions::new().write(true).open(path)?; + cutter.set_len(len)?; + cutter.sync_all()?; + let _ = file; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = path; + file.set_len(len)?; + file.sync_all() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/journal.rs b/crates/ledger-store/src/journal.rs index a86f7a3..f8d8d9b 100644 --- a/crates/ledger-store/src/journal.rs +++ b/crates/ledger-store/src/journal.rs @@ -228,8 +228,7 @@ impl JournalWriter { .and_then(|()| self.file.write_all(b"\n")) .and_then(|()| self.file.sync_all()); if let Err(e) = written { - let _ = self.file.set_len(before); - let _ = self.file.sync_all(); + let _ = crate::chain::truncate(&self.file, &self.path, before); return Err(e.into()); } self.prev_hash = sha256_hex(bytes.as_bytes()); diff --git a/docs/packaging.md b/docs/packaging.md index 5312029..8e5c03f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -213,4 +213,12 @@ not a `cfg` gap. Today Unix-only, found by grep (2026-10-29): host's `close_range` stay Linux-only on purpose (D-32: nothing here confines a process, so nothing is promised where they are absent). macOS, which has no `renameat2`, still looks before it moves. +- Windows file semantics the port met (P11c): an `append(true)` handle has + no right to truncate, so the chain log and the journal cut a torn tail + or roll back a failed write through a short-lived write handle + (`chain::truncate`); a held `LockFileEx` lock is mandatory, so locks sit + on dedicated `*.lock` files and tests never read those; git refuses the + `\\?\` path `canonicalize` returns (`root::plain_path`). std opens every + file with read, write and delete sharing, so a rename never meets an + open handle of ours. - The `portable` job runs Windows as a gate (`experimental: false`), since the port landed (P11c). From 1cc8d967407749fce7f61b828ae348660a396075 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 12:00:55 -0600 Subject: [PATCH 10/12] test(store): the tree helper skips the live SQLite files (P11c) spec_store's tree() read every file under the root while a Store was open; on Windows SQLite holds byte-range locks on its database, so the read failed (os error 33) in two tests. The lock seam locks only wanderbooks.lock, never data, so it is not the cause. tree() now leaves out the live database and its -wal/-shm/-journal; the tests compare the journal, blobs and kept-aside files. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/tests/spec_store.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ledger-store/tests/spec_store.rs b/crates/ledger-store/tests/spec_store.rs index 447724b..5f13dca 100644 --- a/crates/ledger-store/tests/spec_store.rs +++ b/crates/ledger-store/tests/spec_store.rs @@ -125,13 +125,28 @@ fn line_types(dir: &Path) -> Vec { .collect() } -/// Every file under `dir` (relative path → bytes). +/// Whether `p` is the live SQLite database or one of its companions. +fn is_live_database(p: &Path) -> bool { + let name = p.file_name().unwrap().to_string_lossy().into_owned(); + let db = db_file(); + name == db + || ["-wal", "-shm", "-journal"] + .iter() + .any(|x| name == format!("{db}{x}")) +} + +/// Every file under `dir` (relative path → bytes), the live database and +/// its companions left out. fn tree(dir: &Path) -> BTreeMap> { fn walk(root: &Path, d: &Path, out: &mut BTreeMap>) { for e in std::fs::read_dir(d).unwrap() { let p = e.unwrap().path(); if p.is_dir() { walk(root, &p, out); + } else if p.parent() == Some(root) && is_live_database(&p) { + // An open SQLite connection holds byte-range locks on its + // database file on Windows: a plain read is refused. The + // tests compare the journal, blobs and kept-aside files. } else { out.insert( p.strip_prefix(root).unwrap().to_path_buf(), From 4a1bd05b7c8758790385b6ca5cff3995e7ee6696 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 13:58:55 -0600 Subject: [PATCH 11/12] test(store): tree() leaves out the lock files too (P11c) Run 13 failed at the same helper: the store holds LockFileEx locks on wanderbooks.lock and books.lock, which are mandatory on Windows, so reading them is refused. tree() skips any *.lock and the live SQLite files at any depth. The other read_dir walkers in the store, cli and gui tests either read only metadata or copy closed books; the cli harness's book_files already tolerates a refused read. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/tests/spec_store.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/ledger-store/tests/spec_store.rs b/crates/ledger-store/tests/spec_store.rs index 5f13dca..6c6a38b 100644 --- a/crates/ledger-store/tests/spec_store.rs +++ b/crates/ledger-store/tests/spec_store.rs @@ -125,6 +125,12 @@ fn line_types(dir: &Path) -> Vec { .collect() } +/// Whether `p` is a lock file (`wanderbooks.lock`, `books.lock`, and any +/// other `*.lock`): it holds no data. +fn is_lock_file(p: &Path) -> bool { + p.extension().is_some_and(|x| x == "lock") +} + /// Whether `p` is the live SQLite database or one of its companions. fn is_live_database(p: &Path) -> bool { let name = p.file_name().unwrap().to_string_lossy().into_owned(); @@ -135,18 +141,20 @@ fn is_live_database(p: &Path) -> bool { .any(|x| name == format!("{db}{x}")) } -/// Every file under `dir` (relative path → bytes), the live database and -/// its companions left out. +/// Every file under `dir` (relative path → bytes), the live database, its +/// companions and the lock files left out. fn tree(dir: &Path) -> BTreeMap> { fn walk(root: &Path, d: &Path, out: &mut BTreeMap>) { for e in std::fs::read_dir(d).unwrap() { let p = e.unwrap().path(); if p.is_dir() { walk(root, &p, out); - } else if p.parent() == Some(root) && is_live_database(&p) { + } else if is_live_database(&p) || is_lock_file(&p) { // An open SQLite connection holds byte-range locks on its - // database file on Windows: a plain read is refused. The - // tests compare the journal, blobs and kept-aside files. + // database file on Windows, and so does the store on its + // lock files (LockFileEx is mandatory there): a plain read + // is refused. The tests compare the journal, blobs and + // kept-aside files. } else { out.insert( p.strip_prefix(root).unwrap().to_path_buf(), From f2d1d4e892a2e98ba2cf24bc76f041a62f81d9d7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 16:18:04 -0600 Subject: [PATCH 12/12] fix(store): no root keeps the \\?\ prefix; the Windows root test compares canonical homes (P11c) main's canonical default made Root::setup take std::fs::canonicalize(home), which on Windows is \\?\C:\... with the long name. root.rs now uses root::canonical (canonicalize, then the prefix off) at every site, so no root, record or message keeps the prefix; Unix is unchanged (the identity), and macOS still agrees on /tmp and /private/tmp. windows_root.rs compares against default_root(canonical(home)), so TEMP's 8.3 short name is not in the expectation, and asserts the root has no prefix. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/src/root.rs | 24 ++++++++++++++--------- crates/ledger-store/tests/windows_root.rs | 6 +++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 7d9f151..bb394fd 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -290,7 +290,7 @@ pub fn inside_a_root(path: &Path) -> bool { let Some(existing) = path.ancestors().find(|dir| dir.exists()) else { return false; }; - let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf()); + let real = canonical(existing).unwrap_or_else(|_| existing.to_path_buf()); real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file()) } @@ -323,6 +323,13 @@ pub fn without_extended_prefix(text: &str) -> String { text.replace(r"\\?\UNC\", r"\\").replace(r"\\?\", "") } +/// `std::fs::canonicalize` with Windows' extended-length prefix taken off +/// (`\\?\C:\dir` is `C:\dir`): no root, record, message or git argument +/// ever keeps the prefix. +pub fn canonical(path: &Path) -> std::io::Result { + std::fs::canonicalize(path).map(|real| plain_path(&real)) +} + /// [`without_extended_prefix`] for a path (the same path on Unix). pub fn plain_path(path: &Path) -> PathBuf { match path.to_str() { @@ -388,7 +395,7 @@ pub fn short_link_for(dir: &Path) -> std::io::Result { } Err(e) => return Err(e), } - let dir = std::fs::canonicalize(dir)?; + let dir = canonical(dir)?; let link = base.join(&hex::encode(Sha256::digest(dir.as_os_str().as_encoded_bytes()))[..16]); match std::fs::symlink_metadata(&link) { Ok(meta) => { @@ -533,8 +540,7 @@ impl Root { // resolved that way too: on macOS `/tmp` is `/private/tmp`, and a // root chosen at the default must read as the default (so the // record goes), not as somewhere else. - let default = - default_root(&std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf())); + let default = default_root(&canonical(home).unwrap_or_else(|_| home.to_path_buf())); let recorded = recorded_root(home)?; let current = recorded.clone().unwrap_or_else(|| default.clone()); let path = match (chosen, &recorded, xdg_data_home) { @@ -1209,9 +1215,9 @@ impl Root { /// The source of an adoption, made absolute: books outside the root /// (and not holding it) with a journal. fn adoptable(&self, source: &Path) -> Result { - let source = std::fs::canonicalize(source) + let source = canonical(source) .map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?; - let root = std::fs::canonicalize(&self.path)?; + let root = canonical(&self.path)?; if source.starts_with(&root) || root.starts_with(&source) { return Err(RootError::Adopt(format!( "those books are inside {THE_ROOT} (or hold it): adopt books from outside it" @@ -1275,7 +1281,7 @@ impl Root { confirm: bool, ) -> Result { (|| { - let root = std::fs::canonicalize(&self.path)?; + let root = canonical(&self.path)?; let book = self.book_dir(id); if !book.join(JOURNAL_FILE).is_file() { return Err(RootError::Adopt(format!( @@ -1298,7 +1304,7 @@ impl Root { let (Some(parent), Some(name)) = (given.parent(), given.file_name()) else { return Err(RootError::Adopt(format!("{shown} can't be put aside"))); }; - let path = std::fs::canonicalize(parent) + let path = canonical(parent) .map_err(|e| RootError::Adopt(format!("{shown}: {}", e.kind())))? .join(name); let meta = std::fs::symlink_metadata(&path) @@ -1932,7 +1938,7 @@ fn resolve_new(path: &Path) -> Result { _ => break, } } - let mut out = std::fs::canonicalize(existing)?; + let mut out = canonical(existing)?; for n in rest.into_iter().rev() { out.push(n); } diff --git a/crates/ledger-store/tests/windows_root.rs b/crates/ledger-store/tests/windows_root.rs index d9b016d..629ba23 100644 --- a/crates/ledger-store/tests/windows_root.rs +++ b/crates/ledger-store/tests/windows_root.rs @@ -35,7 +35,11 @@ fn the_default_root_sits_under_local_app_data() { 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())); + // The default root of the home as the system names it (its long name, + // no `\\?\` prefix; TEMP may hold an 8.3 short name). + let real_home = ledger_store::root::canonical(home.path()).unwrap(); + assert_eq!(root.path(), default_root(&real_home)); + assert!(!root.path().to_string_lossy().starts_with(r"\\?\")); assert!(report.created && !report.tightened && !report.recorded); assert!(winsec::is_private(root.path()).unwrap()); for sub in ["books", "run", "legacy"] {