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 diff --git a/.github/workflows/portable-windows.yml b/.github/workflows/portable-windows.yml index 6000bd1..a92a585 100644 --- a/.github/workflows/portable-windows.yml +++ b/.github/workflows/portable-windows.yml @@ -25,7 +25,7 @@ jobs: matrix: include: - 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. diff --git a/Cargo.lock b/Cargo.lock index 7ac7ee0..2fb4810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4144,6 +4144,7 @@ dependencies = [ "subtle", "tempfile", "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] diff --git a/README.md b/README.md index 7d1f907..0a25b2b 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/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index b9af5bd..3685c05 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -61,10 +61,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) @@ -179,7 +179,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. @@ -315,7 +314,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, @@ -353,14 +351,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, @@ -376,7 +368,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 { @@ -388,10 +379,19 @@ fn run_local(mode: &str, args: Vec) -> i32 { "token" => ledger_host::commands::token(&prog, args), "connect" => ledger_host::commands::connect(&prog, args), "passphrase" => ledger_host::commands::passphrase(&prog, args), + #[cfg(unix)] "update" => { ledger_host::update::update(&prog, ledger_host::update::program_version(), args) } + #[cfg(unix)] "rollback" => ledger_host::update::rollback(&prog, args), + // Updates replace a running program's file in place, which Windows + // forbids: its updater is P11a step 5. + #[cfg(not(unix))] + "update" | "rollback" => { + eprintln!("error: `{mode}` is not built for Windows yet (P11a step 5)"); + return 2; + } _ => ledger_host::commands::books(&prog, args), }; match result { @@ -408,7 +408,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 { @@ -427,27 +426,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 -} - /// Whether the program was launched with nothing to say and no terminal /// to say it to: a menu entry, a double-click, an AppImage (whose launcher /// runs the desktop file's command and appends what it was given, so the @@ -458,6 +436,7 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool { } fn main() { + #[cfg(unix)] ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION")); // 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 05659c1..e3990eb 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, @@ -194,14 +190,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()) @@ -258,10 +254,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 @@ -846,20 +842,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 @@ -922,8 +969,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/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/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/spec_vault.rs b/crates/ledger-cli/tests/spec_vault.rs index b4c75d3..ed7c527 100644 --- a/crates/ledger-cli/tests/spec_vault.rs +++ b/crates/ledger-cli/tests/spec_vault.rs @@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec { let p = e.path(); if p.is_dir() { out.extend(files_under(&p)); + } else if p.extension().is_some_and(|x| x == "lock") { + // A lock file holds no data, and on Windows a held lock is + // mandatory: reading it is refused while the server runs. } else { out.push(p); } 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-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/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/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 } 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 144cec1..0646efb 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.", @@ -1275,6 +1275,8 @@ pub fn find_agent_in( ".bun/bin", ".cargo/bin", ".volta/bin", + // Where npm puts global programs on Windows. + "AppData/Roaming/npm", ] .into_iter() .filter_map(|d| home.map(|h| h.join(d))) @@ -1288,7 +1290,18 @@ pub fn find_agent_in( .into_iter() .chain(usual) .filter(|d| d.is_absolute()) - .map(|d| d.join(cli)) + .flat_map(|d| { + // Windows runs a program by its extension. + let names: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ".bat"] + } else { + &[""] + }; + names + .iter() + .map(|ext| d.join(format!("{cli}{ext}"))) + .collect::>() + }) .find(|p| is_program(p)) } @@ -1979,8 +1992,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). @@ -2766,10 +2778,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 bc99696..11c1f65 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). @@ -198,8 +205,13 @@ fn children_executable() -> PathBuf { /// A program file's identity: device and inode, size and last write. A /// replaced file differs in the inode, and a rewritten one in the rest. +#[cfg(unix)] type Identity = (u64, u64, u64, i64, i64); +/// Windows: size and last write (nanoseconds). +#[cfg(windows)] +type Identity = (u64, u64); +#[cfg(unix)] fn identity_from(meta: &std::fs::Metadata) -> Identity { ( meta.dev(), @@ -213,6 +225,7 @@ fn identity_from(meta: &std::fs::Metadata) -> Identity { /// The running executable's file identity, and its file's now: they /// differ once the file was replaced (INVARIANT 7). An AppImage is its /// image file (`APPIMAGE`), not the program inside the mount. +#[cfg(unix)] fn running_identity() -> Option { if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) { return Some(identity_from(&std::fs::metadata(image).ok()?)); @@ -225,6 +238,14 @@ fn running_identity() -> Option { Some(identity_from(&running)) } +/// 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 { @@ -233,11 +254,30 @@ fn file_identity() -> Option { /// The identity of the program at `path`: a file someone may run; else /// None. +#[cfg(unix)] fn identity_of(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta)) } +/// 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 { @@ -365,19 +405,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) @@ -474,20 +524,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(); @@ -505,6 +546,7 @@ impl Host { let mut update_poll = tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10))); update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + #[cfg(unix)] let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut idle_at = Some(Instant::now() + self.knobs.close_after); let server_start = tokio::time::sleep(self.knobs.server_delay); @@ -537,9 +579,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(_) => {} @@ -664,17 +706,22 @@ impl Host { } } _ = update_poll.tick(), if matches!(self.phase, Phase::Open) => { - use std::sync::atomic::Ordering; - if !looking.swap(true, Ordering::SeqCst) { - let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); - std::thread::spawn(move || { - match crate::update::stage_if_due(&root, crate::update::program_version(), every) { - Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), - Ok(None) => {} - Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), - } - looking.store(false, Ordering::SeqCst); - }); + // Staging an update replaces the program's file, which + // Windows forbids while it runs: P11a step 5. + #[cfg(unix)] + { + use std::sync::atomic::Ordering; + if !looking.swap(true, Ordering::SeqCst) { + let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); + std::thread::spawn(move || { + match crate::update::stage_if_due(&root, crate::update::program_version(), every) { + Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), + Ok(None) => {} + Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), + } + looking.store(false, Ordering::SeqCst); + }); + } } } _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => { @@ -712,7 +759,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()); } @@ -853,11 +902,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() { @@ -872,31 +920,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); @@ -1057,13 +1096,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") @@ -1099,6 +1156,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 6c17834..65a7693 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; #[cfg(unix)] 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 4409599..931fc2e 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -33,18 +33,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. @@ -75,11 +91,13 @@ pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option 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. @@ -94,6 +112,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 { @@ -103,6 +122,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 { @@ -130,6 +151,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 { @@ -141,6 +163,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( @@ -167,6 +190,7 @@ fn host_env( 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 @@ -182,6 +206,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> { @@ -195,6 +220,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( @@ -293,9 +319,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 @@ -325,6 +353,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() @@ -334,7 +467,7 @@ fn reap(mut child: std::process::Child) { }); } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; @@ -501,3 +634,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/books_home.rs b/crates/ledger-host/tests/books_home.rs index 6bcf230..acb9374 100644 --- a/crates/ledger-host/tests/books_home.rs +++ b/crates/ledger-host/tests/books_home.rs @@ -1,6 +1,7 @@ //! Books homes (P11b, D-27): the folder an agent works in for a book. It //! holds the agent's pointer and settings and nothing of the books. +#[cfg_attr(not(unix), allow(unused_imports))] use ledger_host::commands::{ AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in, }; 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 043d4d5..56f60a6 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); } } @@ -1821,9 +1736,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. @@ -1872,18 +1787,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). @@ -2028,8 +1933,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 { @@ -2295,7 +2206,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() @@ -2314,12 +2224,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() { @@ -2616,29 +2521,32 @@ 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 @@ -2655,8 +2563,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" @@ -3201,6 +3109,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 = @@ -3262,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(); 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/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/backup.rs b/crates/ledger-store/src/backup.rs index c2d9b5b..6117675 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" })) @@ -214,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; @@ -266,8 +281,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 +315,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/chain.rs b/crates/ledger-store/src/chain.rs index e864d99..b7ce5fb 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,9 +206,12 @@ 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()?; + truncate(&file, path, read.good_len)?; } // A log just made is in its folder for good (its entry synced). #[cfg(unix)] @@ -275,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 }; @@ -284,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/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index cf5601e..0ba779d 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -29,6 +29,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; @@ -41,6 +42,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 629627e..bb394fd 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) { @@ -283,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()) } @@ -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 => { @@ -307,6 +314,30 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result 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() { + 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; @@ -364,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) => { @@ -480,7 +511,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!( @@ -509,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) { @@ -554,7 +584,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 \ @@ -643,7 +674,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)?; @@ -1184,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" @@ -1250,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!( @@ -1273,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) @@ -1304,6 +1335,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(); @@ -1554,10 +1587,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 @@ -1852,6 +1892,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() { @@ -1892,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); } @@ -1949,9 +1995,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(()) } @@ -1960,10 +2013,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" @@ -1991,8 +2046,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) } @@ -2010,6 +2075,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)] { @@ -2080,6 +2150,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 } @@ -2216,6 +2287,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. diff --git a/crates/ledger-store/src/winsec.rs b/crates/ledger-store/src/winsec.rs new file mode 100644 index 0000000..9f7f629 --- /dev/null +++ b/crates/ledger-store/src/winsec.rs @@ -0,0 +1,469 @@ +//! 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(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( + pipe.as_raw_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(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( + pipe.as_raw_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(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( + pipe.as_raw_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/spec_store.rs b/crates/ledger-store/tests/spec_store.rs index 447724b..6c6a38b 100644 --- a/crates/ledger-store/tests/spec_store.rs +++ b/crates/ledger-store/tests/spec_store.rs @@ -125,13 +125,36 @@ fn line_types(dir: &Path) -> Vec { .collect() } -/// Every file under `dir` (relative path → bytes). +/// 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(); + 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, 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 is_live_database(&p) || is_lock_file(&p) { + // An open SQLite connection holds byte-range locks on its + // 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(), diff --git a/crates/ledger-store/tests/windows_root.rs b/crates/ledger-store/tests/windows_root.rs new file mode 100644 index 0000000..629ba23 --- /dev/null +++ b/crates/ledger-store/tests/windows_root.rs @@ -0,0 +1,128 @@ +//! 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(); + // 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"] { + 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/docs/architecture.md b/docs/architecture.md index 0f84a63..40f59e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -286,6 +286,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 07063da..8e5c03f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -178,16 +178,47 @@ 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. +- 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). diff --git a/docs/testing.md b/docs/testing.md index 385870f..403f142 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3670,6 +3670,64 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the `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. + ## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29) `wanderbooks update` and `rollback` (`ledger-host/src/update.rs`) are diff --git a/mise.toml b/mise.toml index 88b925e..6ee7afd 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" }, ] diff --git a/plan/served.md b/plan/served.md index 05c6434..6abd15a 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1687,7 +1687,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. **Second CI run (Windows):** `spec_vault` read every file under a book, lock files included; a held `LockFileEx` lock is mandatory, so the read failed (os error 33). The seam already locks dedicated files (`wanderbooks.lock`, `viewer.lock`, `server.lock`), never data files, so the lock-file approach was already in place; the tests now skip `*.lock` files (they hold no data). `update`/`rollback` and the host's update poll are `cfg(unix)` (P11a step 5 is Windows' updater). **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)