P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)

One wanderbooks executable. The books are open while the viewer, an
agent session or a terminal holds them: a host process takes leases on
a socket, starts the server (the hidden mode `wanderbooks server`) with
a pipe on its input, and closes it 10 s after the last lease ends. No
background service. One host even in a race (run/host.lock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:19 -06:00
1 parent 168e5c9846
commit a298d154b6
31 files changed
+11937 -112

No files matched your search

+196 -36
View File
@@ -58,13 +58,19 @@ pub const BOOKS_DIR: &str = "books";
pub const RUN_DIR: &str = "run";
/// Old copies moved aside at adoption, never deleted (plan 5.5).
pub const LEGACY_DIR: &str = "legacy";
/// A book's TIN vault folder, in its own folder.
pub const VAULT_DIR: &str = "vault";
/// A book's key folder, in its own folder.
pub const KEYS_DIR: &str = "keys";
/// The v1 vault key, in a book's `keys/` (P2 to P7).
pub const LEGACY_KEY_FILE: &str = "legacy.key";
/// Held while books are created or adopted, so only one is made at a time.
const BOOKS_LOCK: &str = "books.lock";
/// Staging folders under `books/` start so; none is a valid book id.
const STAGING_PREFIX: &str = ".adopt-";
/// How the root is named in messages (INVARIANT 6).
const THE_ROOT: &str = "the books' private folder";
pub const THE_ROOT: &str = "the books' private folder";
/// Why a root or book operation was refused. No message names the root or
/// a path inside it (INVARIANT 6).
@@ -244,6 +250,91 @@ pub fn default_root(home: &Path) -> PathBuf {
platform_data_dir(home).join(ROOT_NAME)
}
/// Whether `path` is a data root or inside one (any folder above it, or
/// it, holds the marker `setup` leaves). Books there belong to the books
/// server alone (plan 3.1: `dev-stdio` refuses them).
pub fn inside_a_root(path: &Path) -> bool {
// From here (a path that is already absolute stays as it is).
let path = std::env::current_dir()
.map(|here| here.join(path))
.unwrap_or_else(|_| path.to_path_buf());
// Where the deepest folder that exists really is: a folder not made
// yet holds no marker, and a link counts as where it leads.
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());
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
}
/// A folder directly in a book's own folder (`keys/`, `vault/`): made
/// 0700 if missing, made 0700 again if it is ours but others could enter
/// it; refused if it is a symlink or belongs to another user. The error
/// names no path (INVARIANT 6).
pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootError> {
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")?;
tighten_if(&dir, open)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
make_private_dir(&dir)?;
sync_dir(book_dir)?;
}
Err(e) => return Err(e.into()),
}
Ok(dir)
}
/// 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;
/// The path to a socket named `name` in `dir`, short enough for the
/// system: the path itself when it fits; else, on Linux, the same place
/// reached through `/proc/self/fd` and a handle on `dir` kept open while
/// this lives (it is good only in this process). Elsewhere a path too
/// long is refused, saying so plainly (no path named).
#[derive(Debug)]
pub struct SocketPath {
path: PathBuf,
_dir: Option<std::fs::File>,
}
impl SocketPath {
/// The socket `name` in `dir`.
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
let full = dir.join(name);
if full.as_os_str().len() <= SOCKET_PATH_MAX {
return Ok(Self {
path: full,
_dir: None,
});
}
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
let handle = std::fs::File::open(dir)?;
let path = PathBuf::from(format!("/proc/self/fd/{}/{name}", handle.as_raw_fd()));
Ok(Self {
path,
_dir: Some(handle),
})
}
#[cfg(not(target_os = "linux"))]
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"the books' private folder's path is too long for its sockets: choose a shorter place with `wanderbooks setup --root`",
))
}
/// The path to bind or connect to.
pub fn path(&self) -> &Path {
&self.path
}
}
/// The data root.
#[derive(Clone, Debug)]
pub struct Root {
@@ -422,6 +513,14 @@ impl Root {
&self.path
}
/// The root's `run/` folder (its sockets and locks), made 0700 if
/// missing and refused if it isn't private.
pub fn run_dir(&self) -> Result<PathBuf, RootError> {
self.private_subdir(RUN_DIR, false)
.map(|(dir, _)| dir)
.map_err(|e| self.hide(e))
}
/// The folder a book lives in.
pub fn book_dir(&self, id: &BookId) -> PathBuf {
self.path.join(BOOKS_DIR).join(id.as_str())
@@ -562,22 +661,7 @@ impl Root {
if std::fs::symlink_metadata(&dest).is_ok() {
return Err(RootError::Exists(id.to_string()));
}
let source = std::fs::canonicalize(source).map_err(|e| {
RootError::Adopt(format!("can't read the books at {}: {e}", source.display()))
})?;
let root = std::fs::canonicalize(&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"
)));
}
let journal_path = source.join(JOURNAL_FILE);
if !journal_path.is_file() {
return Err(RootError::Adopt(format!(
"there are no books at {}: it has no {JOURNAL_FILE}",
source.display()
)));
}
let source = self.adoptable(source)?;
for (what, path) in [
("vault folder", &options.vault_dir),
("vault key", &options.vault_key),
@@ -586,8 +670,7 @@ impl Root {
&& !path.exists()
{
return Err(RootError::Adopt(format!(
"the {what} given, {}, isn't there: nothing was adopted",
path.display()
"the {what} given isn't there: nothing was adopted"
)));
}
}
@@ -595,16 +678,17 @@ impl Root {
// writes it while it is copied.
let (_source_lock, source_lock_created) = lock_source(&source)?;
// Step 2: verify the chain and record the head.
let raw = std::fs::read(&journal_path)?;
if raw.last() != Some(&b'\n') {
return Err(RootError::Adopt(
"the source's last journal line is cut off: open the books once with the server \
that wrote them (it repairs a cut-off line), then adopt them"
.into(),
));
}
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
let (raw, lines) = read_source_journal(&source)?;
let head = head_of(&lines);
if let Some(expected) = &options.expect_head
&& *expected != head
{
return Err(RootError::Adopt(format!(
"those books changed since the preview (they are now at event {}): nothing was \
adopted; preview them again",
head.0
)));
}
let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set");
if !keeps_settings && options.settings.is_none() {
return Err(RootError::Adopt(
@@ -634,14 +718,14 @@ impl Root {
let copied = copy_blobs(&source.join(blob_dir), &staging.join(blob_dir))?;
// Step 5: the vault's records and the v1 key.
let vault_files = match &options.vault_dir {
Some(dir) => copy_flat_private(dir, &staging.join("vault"))?,
Some(dir) => copy_flat_private(dir, &staging.join(VAULT_DIR))?,
None => 0,
};
let key_copied = match &options.vault_key {
Some(key) => {
let keys = staging.join("keys");
let keys = staging.join(KEYS_DIR);
make_private_dir(&keys)?;
write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?;
write_private_file(&keys.join(LEGACY_KEY_FILE), &std::fs::read(key)?)?;
sync_dir(&keys)?;
true
}
@@ -708,6 +792,79 @@ impl Root {
}
}
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)
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
let root = std::fs::canonicalize(&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"
)));
}
if !source.join(JOURNAL_FILE).is_file() {
return Err(RootError::Adopt(format!(
"there are no books there: it has no {JOURNAL_FILE}"
)));
}
Ok(source)
}
/// What adopting `source` would start from: its head, its base
/// currency, and whether it keeps its own settings, read under the
/// checks [`Self::adopt`] makes, writing nothing (not even a lock
/// file). The adoption itself reads it all again under the lock.
pub fn adopt_preview(&self, source: &Path) -> Result<SourcePreview, RootError> {
(|| {
let source = self.adoptable(source)?;
// Held for writing now: adoption would be refused, so is this.
if let Ok(lock) = std::fs::File::open(source.join(crate::store::LOCK_FILE))
&& let Err(std::fs::TryLockError::WouldBlock) = lock.try_lock_shared()
{
return Err(RootError::from(StoreError::Locked(
"another wanderbooks process has those books open for writing (an agent \
session, or its MCP server); close it first"
.into(),
)));
}
let (_, lines) = read_source_journal(&source)?;
Ok(SourcePreview {
head: head_of(&lines),
currency: crate::store::journal_currency(&lines)?,
keeps_settings: lines.iter().any(|l| l.line_type == "settings.set"),
})
})()
.map_err(|e| self.hide(e))
}
}
/// The source's journal, whole and verified: its bytes and lines.
fn read_source_journal(source: &Path) -> Result<(Vec<u8>, Vec<crate::JournalLine>), RootError> {
let raw = std::fs::read(source.join(JOURNAL_FILE))?;
if raw.last() != Some(&b'\n') {
return Err(RootError::Adopt(
"the source's last journal line is cut off: open the books once with the server \
that wrote them (it repairs a cut-off line), then adopt them"
.into(),
));
}
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
Ok((raw, lines))
}
/// What [`Root::adopt_preview`] found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourcePreview {
/// The source's head, `(seq, hash)`.
pub head: (u64, String),
/// Its base currency.
pub currency: Currency,
/// Whether it keeps its own settings.
pub keeps_settings: bool,
}
/// Options for [`Root::adopt`].
#[derive(Clone, Debug, Default)]
pub struct AdoptOptions {
@@ -725,6 +882,9 @@ pub struct AdoptOptions {
/// The v1 vault key file: copied to the book's `keys/legacy.key`.
/// Given, it must exist.
pub vault_key: Option<PathBuf>,
/// The head the source must have, `(seq, hash)`, as a preview read
/// it: if the books changed since, nothing is adopted.
pub expect_head: Option<(u64, String)>,
}
/// What [`Root::adopt`] did.
@@ -779,11 +939,11 @@ fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> {
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => {
return Err(RootError::from(StoreError::Locked(format!(
"another wanderbooks process has the books in {} open for writing (an agent \
session, or its MCP server); close it first",
source.display()
))));
return Err(RootError::from(StoreError::Locked(
"another wanderbooks process has those books open for writing (an agent \
session, or its MCP server); close it first"
.into(),
)));
}
Err(std::fs::TryLockError::Error(e)) => return Err(e.into()),
}
+1 -1
View File
@@ -6235,7 +6235,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
/// The base currency of a journal: its first line's, which must be a
/// sound `store.created` ([`created_currency`]).
fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
let first = lines
.first()
.ok_or_else(|| StoreError::JournalCorrupt("journal has no store.created line".into()))?;
+57 -1
View File
@@ -112,7 +112,7 @@ pub fn command_path(data_dir: &Path) -> PathBuf {
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
let path = command_path(data_dir);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
private_folder(dir)?;
}
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
let cmd = ViewerCommand {
@@ -129,6 +129,42 @@ pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerC
Ok(cmd)
}
/// The folder the requests go in: made private (0700) if missing, made
/// private again if ours; refused if it is a link or another account's
/// (in a shared temp folder anyone could have planted one, to have a
/// request written through it).
fn private_folder(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::symlink_metadata(dir) {
Ok(meta) => {
// SAFETY: geteuid has no preconditions and can't fail.
let me = unsafe { libc::geteuid() };
if !meta.is_dir() || meta.uid() != me {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"the viewer's request folder isn't this account's own",
));
}
// Private again, whatever it was.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir(dir)?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Err(e) => return Err(e),
}
Ok(())
}
#[cfg(not(unix))]
std::fs::create_dir_all(dir)
}
/// The latest request for `data_dir`, if any.
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
let bytes = std::fs::read(command_path(data_dir)).ok()?;
@@ -170,6 +206,26 @@ mod tests {
);
// Same books, same file; other books, another.
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
// Made private; made private again.
let folder = base.join("wanderbooks");
private_folder(&folder).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&folder), 0o700);
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
private_folder(&folder).unwrap();
assert_eq!(mode(&folder), 0o700);
// A link planted in its place is refused.
let link = base.join("planted");
std::os::unix::fs::symlink(&base, &link).unwrap();
assert!(private_folder(&link).is_err());
let _ = std::fs::remove_dir_all(&base);
}
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
let _ = std::fs::remove_file(command_path(&dir));
}