merge: P11c, the native Windows port (branch P11c, 36a7ad51; D-62): the portable Windows job is green

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-30 19:51:47 -06:00
commit 05aece9fd1
45 files changed
+2487 -506

No files matched your search

+3
View File
@@ -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
+1 -1
View File
@@ -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.
Generated
+1
View File
@@ -4144,6 +4144,7 @@ dependencies = [
"subtle",
"tempfile",
"thiserror 2.0.21",
"windows-sys 0.61.2",
]
[[package]]
+6 -4
View File
@@ -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`).
+12 -33
View File
@@ -61,10 +61,10 @@ fn gui_args(args: Vec<String>) -> Vec<String> {
/// 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<String, String> {
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<String>) -> 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<String>) -> 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<String>) -> i32 {
match ledger_mcp::local::run("wanderbooks server", args) {
Ok(()) => 0,
@@ -376,7 +368,6 @@ fn run_server(args: Vec<String>) -> 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<String>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
@@ -388,10 +379,19 @@ fn run_local(mode: &str, args: Vec<String>) -> 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<String>) -> 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<std::ffi::OsString>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
@@ -427,27 +426,6 @@ fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
}
}
/// The file commands, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_files(_mode: &str, _args: Vec<std::ffi::OsString>) -> 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<String>) -> 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<String>) -> 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.
+68 -22
View File
@@ -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<PathBuf, String> {
/// 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());
+5 -7
View File
@@ -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();
+6 -56
View File
@@ -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}");
}
+3
View File
@@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec<PathBuf> {
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);
}
+1 -2
View File
@@ -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
}
+15 -15
View File
@@ -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();
+4
View File
@@ -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!(
+7
View File
@@ -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
}
+17 -2
View File
@@ -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);
+19 -22
View File
@@ -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<std::collections::HashMap<String, Pending>>,
/// 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<Bridge>, mpsc::UnboundedReceiver<Value>) {
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)
}
+19
View File
@@ -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())
}
}
+23 -12
View File
@@ -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::<Vec<_>>()
})
.find(|p| is_program(p))
}
@@ -1979,8 +1992,7 @@ fn vault_beside(source: &Path) -> Option<PathBuf> {
/// 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())?;
+145 -70
View File
@@ -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<Option<std::fs::File>, String> {
/// [`take_host_lock`], waiting at most `wait` (tests shorten it).
fn take_host_lock_within(run: &Path, wait: Duration) -> Result<Option<std::fs::File>, 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<std::fs::File> {
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<Identity> {
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<Identity> {
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> {
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<Identity> {
@@ -233,11 +254,30 @@ fn file_identity() -> Option<Identity> {
/// The identity of the program at `path`: a file someone may run; else
/// None.
#[cfg(unix)]
fn identity_of(path: &Path) -> Option<Identity> {
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<Identity> {
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<Ending, String> {
let listener = bind(&self.run)?;
let mut listener = bind(&self.run)?;
let (events, mut inbox) = mpsc::unbounded_channel::<Event>();
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<std::fs::File, String> {
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<std::fs::File, String> {
}
/// Bind run/lease.sock, replacing a stale one (only the host holding the
/// lock gets here).
fn bind(run: &Path) -> Result<tokio::net::UnixListener, String> {
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<ledger_mcp::ipc::Listener, String> {
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<u32>,
events: mpsc::UnboundedSender<Event>,
) {
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;
+1 -5
View File
@@ -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)]
+10 -10
View File
@@ -109,8 +109,8 @@ pub struct Opener {
pub struct Lease {
/// Its number.
pub id: u64,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
_write: tokio::net::unix::OwnedWriteHalf,
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<ledger_mcp::ipc::ConnectedRead>>>,
_write: ledger_mcp::ipc::ConnectedWrite,
}
impl Lease {
@@ -207,11 +207,11 @@ impl Opener {
let mut started: Option<Instant> = 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<Asked, String> {
let (read, mut write) = stream.into_split();
async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result<Asked, String> {
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<Option<HostMsg>, String> {
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, 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
+175 -1
View File
@@ -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<std::ffi::OsStrin
}
/// This account's user ID.
#[cfg(unix)]
fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail.
unsafe { libc::geteuid() }
}
#[cfg(unix)]
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
/// runtime folder: absolute, a real folder (no link), owned by this
/// account, and closed to everyone else.
@@ -94,6 +112,7 @@ pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
(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=<runtime>/bus`, perhaps with a guid after a comma).
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
@@ -103,6 +122,7 @@ pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
}
#[cfg(unix)]
/// This account's runtime folder: the one named in the environment if it
/// is ours and private; else, when the opener's environment has none (a
/// client that scrubs it, as Codex does), the system's for this account,
@@ -115,6 +135,7 @@ fn runtime_dir() -> Option<PathBuf> {
.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<String> {
@@ -130,6 +151,7 @@ fn session_bus(runtime: &Path) -> Option<String> {
}
}
#[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<std::ffi::OsString> {
@@ -141,6 +163,7 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
.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<std::ffi::OsString> = 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<std::process::Child, String> {
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"));
}
}
+3
View File
@@ -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);
+1
View File
@@ -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,
};
+53
View File
@@ -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<u32> {
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));
}
+45 -34
View File
@@ -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<Response, CallError> {
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<u8>,
) -> (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
);
}
}
}
+793
View File
@@ -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<u32>;
}
/// 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<u32> {
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<Vec<StopSignal>> {
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<Endpoint> {
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<Listener> {
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<Accepted> {
self.0.accept().await.map(|(stream, _)| stream)
}
/// The address of an accepted connection.
pub fn addr_of(stream: &Accepted) -> io::Result<Addr> {
stream.peer_addr()
}
/// The address this listener is bound to.
pub fn local_addr(&self) -> io::Result<Addr> {
self.0.local_addr()
}
}
/// Connect to `endpoint`.
pub async fn connect(endpoint: &Endpoint) -> io::Result<Connected> {
tokio::net::UnixStream::connect(endpoint.path()).await
}
/// Connect to `endpoint`, for blocking code.
pub fn connect_std(endpoint: &Endpoint) -> io::Result<StdStream> {
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<u32> {
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::<libc::ucred>() 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::<libc::ucred>() {
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<u32> {
#[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::<libc::ucred>() 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::<libc::ucred>() && cred.pid > 0)
.then_some(cred.pid as u32)
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
let _ = fd;
None
}
}
impl<T: AsRawFd> Peer for T {
fn peer_is_us(&self) -> bool {
peer_uid(self).is_ok_and(|uid| uid == own_uid())
}
fn peer_pid(&self) -> Option<u32> {
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<Vec<RawStop>> {
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<NamedPipeServer>;
/// The writing half of an accepted connection.
pub type AcceptedWrite = tokio::io::WriteHalf<NamedPipeServer>;
/// The reading half of a connection this side made.
pub type ConnectedRead = tokio::io::ReadHalf<NamedPipeClient>;
/// The writing half of a connection this side made.
pub type ConnectedWrite = tokio::io::WriteHalf<NamedPipeClient>;
/// 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-<hash of the
/// root's run/ folder>-<name>` (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<Endpoint> {
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<NamedPipeServer> {
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<io::Result<NamedPipeServer>>,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
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<Listener> {
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<Accepted> {
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<Addr> {
Ok(())
}
/// The address this listener is bound to: none.
pub fn local_addr(&self) -> io::Result<Addr> {
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<winsec::OwnerOnly>,
tx: mpsc::UnboundedSender<io::Result<NamedPipeServer>>,
) {
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<Connected> {
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<Option<Duration>>,
}
impl StdStream {
/// Give up reading after `timeout` (INVARIANT 5); `None` waits
/// for ever.
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
*self.read_timeout.lock().unwrap_or_else(|e| e.into_inner()) = timeout;
Ok(())
}
fn read_some(&self, buf: &mut [u8]) -> io::Result<usize> {
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<usize> {
self.read_some(buf)
}
}
impl Read for &StdStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.read_some(buf)
}
}
impl Write for StdStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
(&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<usize> {
(&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<StdStream> {
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<u32> {
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<u32> {
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<u32> {
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<Vec<RawStop>> {
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());
}
}
+1 -1
View File
@@ -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;
+41 -128
View File
@@ -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<u32> {
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::<libc::ucred>() 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::<libc::ucred>() {
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<u32> {
#[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::<libc::ucred>() 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::<libc::ucred>() && 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<Books>) {
async fn serve_admin(stream: ipc::Accepted, books: Arc<Books>) {
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<Books>) {
/// 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<tokio::net::UnixListener> {
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> {
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();
+6 -2
View File
@@ -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 {
+16
View File
@@ -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"
+40 -9
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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<String, BackupError> {
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();
+29 -4
View File
@@ -188,6 +188,7 @@ impl ChainLog {
genesis_text: &str,
) -> Result<(Self, Vec<Map<String, Value>>), 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::*;
+1 -2
View File
@@ -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());
+3
View File
@@ -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;
+36
View File
@@ -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,
}
}
+20 -24
View File
@@ -238,13 +238,8 @@ impl Presence {
/// (None until it has: the viewer makes nothing).
pub fn hold(data_dir: &Path) -> Option<Presence> {
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() {
+113 -27
View File
@@ -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<PathBuf, RootError> {
#[cfg(unix)]
{
@@ -190,11 +190,9 @@ pub fn account_home() -> Result<PathBuf, RootError> {
}
#[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<u8>)> {
/// 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<PathBuf, RootE
let dir = book_dir.join(name);
match std::fs::symlink_metadata(&dir) {
Ok(meta) => {
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<PathBuf, RootE
Ok(dir)
}
/// `text` with Windows' extended-length prefix taken off the paths in it:
/// `\\?\C:\dir` becomes `C:\dir` and `\\?\UNC\server\share` becomes
/// `\\server\share`. `std::fs::canonicalize` returns that form on Windows,
/// and programs such as git refuse it. Text without the prefix is
/// returned as it is, on every system (so the rule is tested everywhere).
pub fn without_extended_prefix(text: &str) -> String {
text.replace(r"\\?\UNC\", r"\\").replace(r"\\?\", "")
}
/// `std::fs::canonicalize` with Windows' extended-length prefix taken off
/// (`\\?\C:\dir` is `C:\dir`): no root, record, message or git argument
/// ever keeps the prefix.
pub fn canonical(path: &Path) -> std::io::Result<PathBuf> {
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<PathBuf> {
}
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<PathBuf, RootError> {
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<PutAside, RootError> {
(|| {
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<Option<PathBuf>, 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<PathBuf, RootError> {
_ => 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<bool, RootError> {
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<u64, RootError> {
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.
+469
View File
@@ -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<u64>);
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::<u8>(), 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<String> {
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<u16> {
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<Sid> {
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::<TOKEN_USER>()).User.Sid) })
}
/// This process's account.
pub fn current_user() -> io::Result<Sid> {
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<Sid> {
// 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<Descriptor> {
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<String> {
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<bool> {
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::<windows_sys::Win32::Security::ACE_HEADER>() };
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::<windows_sys::Win32::Security::ACCESS_ALLOWED_ACE>()).SidStart
)
}
.cast::<c_void>();
// 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<PathBuf> {
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<PathBuf> {
known_folder(&FOLDERID_Profile)
}
/// This account's local application data folder (`%LOCALAPPDATA%`,
/// wherever it was redirected to).
pub fn local_app_data() -> Option<PathBuf> {
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<OwnerOnly> {
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::<SECURITY_ATTRIBUTES>() 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<Option<Sid>> = 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<u32> {
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<u32> {
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<u32> {
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)
}
+24 -1
View File
@@ -125,13 +125,36 @@ fn line_types(dir: &Path) -> Vec<String> {
.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<PathBuf, Vec<u8>> {
fn walk(root: &Path, d: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
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(),
+128
View File
@@ -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-"));
}
+23
View File
@@ -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-<hash of run/>-<name>` 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
+41 -10
View File
@@ -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-<hash of the root's run/ folder>-<name>`, 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).
+58
View File
@@ -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
+1 -1
View File
@@ -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" },
]
+1 -1
View File
@@ -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-<sha256 of the canonical, lower-cased run/ folder, 8 bytes>-<name>`, 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, crLine truncated
#### P12: max testing, again (D-33)