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

+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;
+37 -127
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);
}
}
@@ -1818,9 +1733,9 @@ async fn not_found() -> (axum::http::StatusCode, &'static str) {
}
/// One admin connection: one request, one answer (admin INVARIANT 1).
async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<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.
@@ -1869,18 +1784,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).
@@ -2025,8 +1930,14 @@ pub fn run(
}
{
// Only once it is ours: a refused server touches nothing.
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
}
#[cfg(windows)]
ledger_store::winsec::set_owner_only(&lock_path)
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
}
let books = Arc::new(Books {
@@ -2292,7 +2203,6 @@ async fn serve(
/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the
/// runtime, before the books are opened.
fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
use tokio::signal::unix::{SignalKind, signal};
let on_eof = stops.close.clone();
// A plain thread: a blocked read must never hold up the runtime's end.
std::thread::Builder::new()
@@ -2311,12 +2221,7 @@ fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
}
on_eof.cancel();
})?;
for kind in [
SignalKind::terminate(),
SignalKind::interrupt(),
SignalKind::hangup(),
] {
let mut signals = signal(kind)?;
for mut signals in ipc::stop_signals()? {
let (close, hurry) = (stops.close.clone(), stops.hurry.clone());
tokio::spawn(async move {
while signals.recv().await.is_some() {
@@ -2613,34 +2518,38 @@ mod tests {
assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None);
}
#[cfg(unix)]
#[test]
fn a_socket_pair_is_this_account_at_both_ends() {
let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
assert_eq!(peer_uid(&a).unwrap(), own_uid());
assert!(peer_is_us(&b));
assert_eq!(ipc::peer_uid(&a).unwrap(), ipc::own_uid());
assert!(ipc::peer_is_us(&b));
assert!(check_peer_std(&a).is_ok());
}
#[cfg(unix)]
#[test]
fn a_socket_pair_names_this_process() {
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
if cfg!(target_os = "linux") {
assert_eq!(peer_pid(&a), Some(std::process::id()));
assert_eq!(ipc::peer_pid(&a), Some(std::process::id()));
}
let file = std::fs::File::open("/dev/null").unwrap();
assert_eq!(peer_pid(&file), None);
assert_eq!(ipc::peer_pid(&file), None);
}
#[cfg(unix)]
#[test]
fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() {
let file = std::fs::File::open("/dev/null").unwrap();
assert!(peer_uid(&file).is_err());
assert!(!peer_is_us(&file));
assert!(ipc::peer_uid(&file).is_err());
assert!(!ipc::peer_is_us(&file));
}
/// A socket with no other end: the system answers for it (Linux: no
/// process, no user), and that answer names neither this process nor
/// this account; the client's check refuses it.
#[cfg(unix)]
#[test]
fn a_socket_with_no_other_end_names_no_process_and_isnt_us() {
use std::os::fd::FromRawFd;
@@ -2650,8 +2559,8 @@ mod tests {
assert!(fd >= 0);
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
assert_eq!(peer_pid(&lone), None);
assert!(!peer_is_us(&lone));
assert_eq!(ipc::peer_pid(&lone), None);
assert!(!ipc::peer_is_us(&lone));
assert_eq!(
check_peer_std(&lone).unwrap_err(),
"the books server's socket belongs to another account: not used"
@@ -3196,6 +3105,7 @@ mod tests {
let id = BookId::parse(id).unwrap();
let dir = books.root.book_dir(&id);
std::fs::create_dir(&dir).unwrap();
#[cfg(unix)]
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
.unwrap();
let mut s =