feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)

Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge,
the opener, the books server and the command line is gone: they build on
every OS, and the Unix behaviour is unchanged (the existing over-the-wire
suites pass on Linux).

`ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600
sockets as before, peer uid unchanged (the peer functions moved here).
Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>`
made with a DACL of one allow entry for this account's SID, remote
clients refused, the first instance made once (a second server is
refused, as a held lock refuses it on Unix), and 16 idle instances kept
waiting so a client never finds all busy while the server makes the next.
Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's
process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and
compares that process's token user with its own SID; any doubt is "not
us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot
act as the client. A blocking client with a read limit is polled with
PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has
gone reads as the end of the stream, as a closed socket does. Stop
signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has
SIGTERM, SIGINT and SIGHUP.

The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
leaving the opener's job when the job lets it: an agent's job must not
take the host down, as an app's systemd scope must not on Linux) with a
fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A
replaced program is told by size and last write. Git for Windows is found
and a quiet git is stopped with its tree (`taskkill /T`). The test
harness of ledger-cli finds its root by `default_root`, cleans a child's
environment per OS and reaches the server over its pipe; the bridge's
scripted-server tests stay Unix-only.

No new dependency (tokio's named pipes and reqwest's `windows_named_pipe`
were already in the build); `windows-sys` stays in ledger-store.

Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only),
ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`,
Windows variants of the host and start tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:02:12 -06:00
1 parent 5f5dd8f7da
commit bd6448532e
21 files changed
+1471 -410

No files matched your search

+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())
}
}
+38 -16
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.",
@@ -1160,6 +1160,20 @@ pub fn connect(
}
}
/// Whether `p` is a file someone may run (Windows: a file; what it can
/// run as is told by its extension, which the caller chose).
fn is_runnable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
std::fs::metadata(p).is_ok_and(|m| m.is_file())
}
}
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
/// their token kept in its token file (made if none is), and `wanderbooks
/// mcp --book <book>` added to the agent's own list as
@@ -1176,7 +1190,7 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
// The PATH first; then where the agents' installers put them (a
// viewer started from the desktop has a short PATH).
// ($HOME: a test's own home is never the account's.)
let home = std::env::var_os("HOME")
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(PathBuf::from)
.filter(|h| h.is_absolute());
let usual = [
@@ -1185,6 +1199,8 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
".bun/bin",
".cargo/bin",
".volta/bin",
// Where npm puts global programs on Windows.
"AppData/Roaming/npm",
]
.into_iter()
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
@@ -1199,11 +1215,19 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
.into_iter()
.chain(usual)
.filter(|d| d.is_absolute())
.map(|d| d.join(cli))
.find(|p| {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.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_runnable(p))
.ok_or_else(|| {
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
})?;
@@ -1610,8 +1634,7 @@ fn vault_beside(source: &Path) -> Option<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).
@@ -2397,10 +2420,9 @@ fn on_the_socket(
)
})?;
let token = zeroize::Zeroizing::new(token);
let socket = ledger_store::root::SocketPath::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
let socket = ledger_mcp::ipc::Endpoint::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
.map_err(|e| format!("can't reach the books server: {e}"))?;
let http = reqwest::Client::builder()
.unix_socket(socket.path())
let http = crate::channel::http_builder(&socket)
.no_proxy()
.build()
.map_err(|e| e.to_string())?;
+129 -62
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).
@@ -196,9 +203,13 @@ fn children_executable() -> PathBuf {
}
}
/// A program file's identity: what changes when the file is replaced.
type Identity = (u64, u64);
/// The running executable's file identity, and its file's now: they
/// differ once the file was replaced (INVARIANT 7).
fn running_identity() -> Option<(u64, u64)> {
#[cfg(unix)]
fn running_identity() -> Option<Identity> {
let running = if cfg!(target_os = "linux") {
std::fs::metadata("/proc/self/exe").ok()?
} else {
@@ -207,19 +218,46 @@ fn running_identity() -> Option<(u64, u64)> {
Some((running.dev(), running.ino()))
}
/// Windows can't replace a program that runs, so an update puts the new
/// file at the old path (the old one moved aside): the identity is the
/// file's size and last write at the path, read when the host started.
#[cfg(windows)]
fn running_identity() -> Option<Identity> {
identity_of(&std::env::current_exe().ok()?)
}
/// The identity of the file at this executable's path, if it is a
/// program (a file half-written or gone doesn't count yet).
fn file_identity() -> Option<(u64, u64)> {
fn file_identity() -> Option<Identity> {
identity_of(&crate::start::this_executable().ok()?)
}
/// The identity (device, inode) of the program at `path`: a file someone
/// may run; else None.
fn identity_of(path: &Path) -> Option<(u64, u64)> {
#[cfg(unix)]
fn identity_of(path: &Path) -> Option<Identity> {
let meta = std::fs::metadata(path).ok()?;
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
}
/// The identity (size, last write in nanoseconds) of the program at
/// `path`: a file; else None.
#[cfg(windows)]
fn identity_of(path: &Path) -> Option<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 {
@@ -347,19 +385,29 @@ pub fn run(
/// Start the next host from the file now on disk, with this one's
/// arguments and (already clean) environment (INVARIANT 7).
fn restart() -> Result<(), String> {
use std::os::unix::process::CommandExt;
let exe = crate::start::this_executable()?;
let mut c = std::process::Command::new(exe);
c.args(std::env::args_os().skip(1))
.current_dir("/")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null());
// SAFETY: setsid is async-signal-safe and touches only the child.
unsafe {
c.pre_exec(|| {
libc::setsid();
Ok(())
});
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
c.current_dir("/");
// SAFETY: setsid is async-signal-safe and touches only the child.
unsafe {
c.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Detached from this console and its Ctrl+C, as a new session is
// on Unix.
c.creation_flags(crate::start::DETACHED_HOST);
}
c.spawn()
.map(drop)
@@ -456,20 +504,11 @@ impl Host {
/// Bind the lease socket, start the server, and serve leases until
/// the books close.
async fn serve(mut self) -> Result<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();
@@ -513,9 +552,9 @@ impl Host {
}
}
accepted = listener.accept() => match accepted {
Ok((stream, _)) if ledger_mcp::local::peer_is_us(&stream) => {
Ok(stream) if ledger_mcp::ipc::peer_is_us(&stream) => {
// The holder's process, as the kernel knows it.
let pid = ledger_mcp::local::peer_pid(&stream);
let pid = ledger_mcp::ipc::peer_pid(&stream);
connections.spawn(connection(stream, pid, events.clone()));
}
Ok(_) => {}
@@ -674,7 +713,9 @@ impl Host {
let log = open_log(&self.run.join(SERVER_LOG))
.map_err(|e| format!("can't open the server's log: {}", e.kind()))?;
let mut command = tokio::process::Command::new(children_executable());
command.arg0("wanderbooks").arg("server");
#[cfg(unix)]
command.arg0("wanderbooks");
command.arg("server");
if self.explicit_root {
command.arg("--root").arg(self.root.path());
}
@@ -815,11 +856,10 @@ fn consented(socket: &Path, book: String, passphrase: ledger_mcp::admin::Passphr
/// Take run/update.lock (exclusive), if it is free now.
fn take_update_lock(run: &Path) -> Result<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() {
@@ -834,31 +874,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);
@@ -1019,13 +1050,31 @@ mod tests {
"123456"
);
assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log");
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
0o600
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[cfg(windows)]
#[test]
fn a_folder_the_host_can_write_isnt_a_sandbox() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(confined(dir.path()), None);
assert_eq!(
std::fs::read_dir(dir.path()).unwrap().count(),
0,
"its probe went"
);
// A folder that isn't there: no sign of a sandbox.
assert_eq!(confined(&dir.path().join("gone")), None);
}
#[cfg(unix)]
#[test]
fn a_folder_the_host_can_write_isnt_a_sandbox() {
let seccomp = std::fs::read_to_string("/proc/self/status")
@@ -1061,6 +1110,24 @@ mod tests {
assert_eq!(confined(&dir.path().join("gone")), None);
}
#[cfg(windows)]
#[test]
fn only_a_file_has_an_identity_and_a_replaced_one_another() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("wanderbooks.exe");
std::fs::write(&file, "x").unwrap();
let first = identity_of(&file).expect("a file");
// Replaced by another file (as an update does): another identity.
let next = dir.path().join("next.exe");
std::fs::write(&next, "yy").unwrap();
std::fs::rename(&next, &file).unwrap();
let second = identity_of(&file).unwrap();
assert_ne!(first, second);
assert_eq!(identity_of(dir.path()), None, "a folder isn't one");
assert_eq!(identity_of(&dir.path().join("gone")), None);
}
#[cfg(unix)]
#[test]
fn only_a_program_file_has_an_identity() {
use std::os::unix::fs::PermissionsExt;
+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;
+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
@@ -28,18 +28,34 @@
//! ```
use crate::knobs::Knobs;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::time::{Duration, Instant};
/// The host's PATH (INVARIANT 1).
#[cfg(unix)]
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
/// Where systemd-run may be (never looked up on the opener's PATH).
#[cfg(unix)]
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
/// Windows: a process of its own, without this console, its Ctrl+C or its
/// window (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP): what `setsid`
/// is on Unix.
#[cfg(windows)]
pub(crate) const DETACHED_HOST: u32 = 0x0000_0008 | 0x0000_0200;
/// Windows: leave the job the opener runs in, if it lets its processes
/// (CREATE_BREAKAWAY_FROM_JOB): a host must not end with an agent's job,
/// as it must not end with an app's scope on Linux (INVARIANT 3).
#[cfg(windows)]
const BREAKAWAY: u32 = 0x0100_0000;
/// The host's log, in the root's `run/`.
pub const HOST_LOG: &str = "host.log";
/// A log larger than this is moved aside when a host starts.
@@ -60,11 +76,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
}
/// This account's user ID.
#[cfg(unix)]
fn own_uid() -> u32 {
// SAFETY: geteuid has no preconditions and can't fail.
unsafe { libc::geteuid() }
}
#[cfg(unix)]
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
/// runtime folder: absolute, a real folder (no link), owned by this
/// account, and closed to everyone else.
@@ -79,6 +97,7 @@ pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<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> {
@@ -88,6 +107,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,
@@ -100,6 +120,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> {
@@ -115,6 +136,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> {
@@ -126,6 +148,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(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
@@ -145,6 +168,7 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
env
}
#[cfg(unix)]
/// Whether this process sits in a unit under this account's user manager
/// (an app's scope or service, as uwsm makes for every app), whose stop
/// would take the host with it; going by the unified cgroup line of
@@ -160,6 +184,7 @@ pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
})
}
#[cfg(unix)]
/// systemd-run, if this process is in an app scope and the user manager
/// can be reached (INVARIANT 3).
fn systemd_run() -> Option<&'static str> {
@@ -173,6 +198,7 @@ fn systemd_run() -> Option<&'static str> {
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
}
#[cfg(unix)]
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
pub fn start_host(
@@ -266,9 +292,11 @@ pub fn start_host(
Ok(())
}
#[cfg(unix)]
/// How long systemd-run has to make its scope and run the host.
const SCOPE_WAIT: Duration = Duration::from_secs(2);
#[cfg(unix)]
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
/// host's first line is in its log after `from`, or systemd-run is still
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
@@ -298,6 +326,111 @@ fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -
}
}
/// The host's environment on Windows (INVARIANT 1's counterpart): the
/// system's own variables, the account's profile, and a PATH fixed to the
/// system folders and Git for Windows (the backups run `git`). Nothing
/// else of the opener's passes. (CHOICE, P11c: no authority states a
/// Windows list; this is the least a Windows program and `git` need.)
#[cfg(windows)]
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
let var = |name: &str| std::env::var_os(name);
let system_root: PathBuf = var("SystemRoot")
.or_else(|| var("windir"))
.map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
let program_files: PathBuf =
var("ProgramFiles").map_or_else(|| PathBuf::from(r"C:\Program Files"), PathBuf::from);
let path = std::env::join_paths([
system_root.join("System32"),
system_root.clone(),
program_files.join("Git").join("cmd"),
])
.unwrap_or_default();
let mut env: Vec<(String, std::ffi::OsString)> = vec![
("PATH".into(), path),
("SystemRoot".into(), system_root.clone().into_os_string()),
("windir".into(), system_root.into_os_string()),
("USERPROFILE".into(), home.as_os_str().to_os_string()),
("HOME".into(), home.as_os_str().to_os_string()),
("TEMP".into(), std::env::temp_dir().into_os_string()),
("TMP".into(), std::env::temp_dir().into_os_string()),
];
if let Some(local) = ledger_store::winsec::local_app_data() {
env.push(("LOCALAPPDATA".into(), local.into_os_string()));
}
for name in ["APPDATA", "ComSpec", "PATHEXT", "ProgramFiles"] {
if let Some(value) = var(name) {
env.push((name.into(), value));
}
}
env
}
/// Start a host for the root (`root`, else the one the host finds
/// itself), with these knobs and these server flags (INVARIANTS 1, 2, 4
/// and 5; there is no scope to leave, but a job to break away from).
#[cfg(windows)]
pub fn start_host(
root: Option<&Path>,
knobs: &Knobs,
server_flags: &[String],
) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
let exe = this_executable()?;
let mut args: Vec<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()
@@ -307,7 +440,7 @@ fn reap(mut child: std::process::Child) {
});
}
#[cfg(test)]
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
@@ -457,3 +590,44 @@ mod tests {
assert!(exe.is_file(), "{exe:?}");
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
#[test]
fn this_executable_is_a_file_on_disk() {
let exe = this_executable().unwrap();
assert!(exe.is_file(), "{exe:?}");
}
#[test]
fn the_host_environment_carries_nothing_of_the_openers_choosing() {
let env = host_env(Path::new(r"C:\Users\someone"));
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
for n in &names {
assert!(
[
"PATH",
"SystemRoot",
"windir",
"USERPROFILE",
"HOME",
"TEMP",
"TMP",
"LOCALAPPDATA",
"APPDATA",
"ComSpec",
"PATHEXT",
"ProgramFiles"
]
.contains(n),
"{n}"
);
}
let home = env.iter().find(|(n, _)| n == "HOME").unwrap();
assert_eq!(home.1, r"C:\Users\someone");
let path = env.iter().find(|(n, _)| n == "PATH").unwrap();
assert!(path.1.to_string_lossy().to_lowercase().contains("system32"));
}
}
+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);