merge: main into P11c (the short socket link, P8's audit gate fix, the portable workflow's own file)
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:
commit
ef7e3be28c
5 files changed
+215
-45
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`",
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user