merge: main into P11c (the short socket link, P8's audit gate fix, the portable workflow's own file)
portable / portable (macos-latest) (push) Waiting to run
portable / portable (windows-latest) (push) Waiting to run
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

One conflict: main moved the portable job out of check.yml into
portable.yml; took that and carried experimental: false for windows-latest
into portable.yml. root.rs merged without conflict (short_link_for is
cfg(unix); the Windows arms are untouched).

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 18:11:34 -06:00
commit ef7e3be28c
5 files changed
+215 -45

No files matched your search

+7 -2
View File
@@ -1302,15 +1302,20 @@ fn happy_path(name: &str) -> Mcp {
"books.export",
json!({"format": "iif", "report": "general_journal", "as_of": "2026-12-31"}),
);
// The script ran an audit above, so the package's gate (P8) is live
// and the run no longer stands at the head: the owner's override, as
// a human would give it.
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
"allow_open_drafts": {"reason": "the test books keep drafts open"},
"without_audit_reason": "the script's audit is behind the head; the test exports anyway"}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false,
"allow_open_drafts": {"reason": "the test books keep drafts open"}}),
"allow_open_drafts": {"reason": "the test books keep drafts open"},
"without_audit_reason": "the script's audit is behind the head; the test exports anyway"}),
);
// The access log, the backups' status, and a backup now (none is set
+79 -3
View File
@@ -321,14 +321,82 @@ 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).
/// this lives (it is good only in this process); on the other Unixes
/// (macOS), the same place reached through a short link of this
/// account's under `/tmp` (`short_link_for`), which the server and its
/// clients compute alike. A root in a deep home on a Mac (the portable
/// job found it: a test home under the runner's temp folder plus
/// `Library/Application Support/wanderbooks/run/`) is served as any other.
#[derive(Debug)]
pub struct SocketPath {
path: PathBuf,
_dir: Option<std::fs::File>,
}
/// A short path that reaches `dir`: the symbolic link
/// `/tmp/wanderbooks-<uid>/<sha256 of dir, 16 hex>` → `dir`, inside a
/// folder of this account's alone (made 0700; refused if it is anyone
/// else's, writable by others, or not a folder, since `/tmp` is shared),
/// and the link itself refused unless it is this account's and names
/// `dir` exactly (a stale or foreign link is replaced). Short enough for
/// a socket under it with the longest name in use.
#[cfg(unix)]
pub fn short_link_for(dir: &Path) -> std::io::Result<PathBuf> {
use sha2::{Digest, Sha256};
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
let not_ours = |what: &str| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("{what} under /tmp isn't this account's own private one: remove it"),
)
};
// SAFETY: getuid has no preconditions and never fails.
let uid = unsafe { libc::getuid() };
let base = PathBuf::from("/tmp").join(format!("wanderbooks-{uid}"));
match std::fs::symlink_metadata(&base) {
Ok(meta) => {
if !meta.file_type().is_dir()
|| meta.uid() != uid
|| meta.permissions().mode() & 0o077 != 0
{
return Err(not_ours("the sockets' folder"));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::DirBuilder::new().mode(0o700).create(&base)?;
let meta = std::fs::symlink_metadata(&base)?;
if meta.uid() != uid || meta.permissions().mode() & 0o077 != 0 {
return Err(not_ours("the sockets' folder"));
}
}
Err(e) => return Err(e),
}
let dir = std::fs::canonicalize(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) => {
let same = meta.file_type().is_symlink()
&& meta.uid() == uid
&& std::fs::read_link(&link).map(|t| t == dir).unwrap_or(false);
if same {
return Ok(link);
}
if meta.uid() != uid {
return Err(not_ours("the socket link"));
}
if meta.file_type().is_dir() {
std::fs::remove_dir(&link)?;
} else {
std::fs::remove_file(&link)?;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
std::os::unix::fs::symlink(&dir, &link)?;
Ok(link)
}
impl SocketPath {
/// The socket `name` in `dir`.
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
@@ -349,7 +417,15 @@ impl SocketPath {
_dir: Some(handle),
})
}
#[cfg(not(target_os = "linux"))]
#[cfg(all(unix, not(target_os = "linux")))]
{
let short = short_link_for(dir)?;
Ok(Self {
path: short.join(name),
_dir: None,
})
}
#[cfg(not(unix))]
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`",
+68
View File
@@ -0,0 +1,68 @@
//! Socket paths for roots too deep for a Unix socket's address (about 104
//! bytes): Linux reaches them through `/proc/self/fd`, the other Unixes
//! through a short private link under `/tmp`, so a books root in a deep
//! home is served like any other (the portable macOS job found the
//! refusal).
#![cfg(unix)]
use ledger_store::root::{SocketPath, short_link_for};
use std::path::PathBuf;
fn deep_dir(tag: &str) -> PathBuf {
let deep = std::env::temp_dir()
.join(format!("wb-socket-{tag}-{}", std::process::id()))
.join("a".repeat(60))
.join("b".repeat(60))
.join("run");
std::fs::create_dir_all(&deep).unwrap();
deep
}
/// A root deeper than the address allows still gets a socket path that
/// fits, and a second asker (a client) reaches the same place.
#[test]
fn a_deep_root_gets_a_socket_path_that_fits() {
let deep = deep_dir("fits");
assert!(deep.as_os_str().len() > 100);
let a = SocketPath::new(&deep, "mcp.sock").unwrap();
assert!(a.path().as_os_str().len() <= 100, "{:?}", a.path());
// The place it reaches is the dir itself.
let reached = std::fs::canonicalize(a.path().parent().unwrap()).unwrap();
assert_eq!(reached, std::fs::canonicalize(&deep).unwrap());
}
/// The short link is this account's, names the dir exactly, is the same
/// on a second ask, and replaces a stale link of ours in its place.
#[test]
fn the_short_link_is_private_stable_and_replaces_a_stale_one() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let deep = deep_dir("link");
let link = short_link_for(&deep).unwrap();
assert!(
link.as_os_str().len() + "/lease.sock".len() <= 100,
"{link:?}"
);
// SAFETY: getuid has no preconditions and never fails.
let uid = unsafe { libc::getuid() };
assert_eq!(
link.parent().unwrap(),
PathBuf::from(format!("/tmp/wanderbooks-{uid}"))
);
let folder = std::fs::symlink_metadata(link.parent().unwrap()).unwrap();
assert_eq!(folder.uid(), uid);
assert_eq!(folder.permissions().mode() & 0o777, 0o700);
assert_eq!(
std::fs::read_link(&link).unwrap(),
std::fs::canonicalize(&deep).unwrap()
);
assert_eq!(short_link_for(&deep).unwrap(), link);
// A stale link of ours (to somewhere else) in that place is replaced.
let elsewhere = deep_dir("elsewhere");
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&elsewhere, &link).unwrap();
assert_eq!(short_link_for(&deep).unwrap(), link);
assert_eq!(
std::fs::read_link(&link).unwrap(),
std::fs::canonicalize(&deep).unwrap()
);
}