From db7910f9372e9c07651c3453da94cd203a084c25 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 17:37:39 -0600 Subject: [PATCH 1/3] ci: the portable macOS and Windows jobs run as their own workflow GitHub serves a job's log only when its whole run has completed, and the Linux `check` takes over an hour, so a macOS failure's reason was unreadable for that long while the portable jobs sat in the same run. Each OS's run now completes on its own; the jobs are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/check.yml | 40 ------------------------- .github/workflows/portable.yml | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/portable.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 299ee16..6f78332 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -34,43 +34,3 @@ jobs: - name: What the build used if: always() run: df -h / | tail -1 - # The destination is one installation for macOS, Windows and Linux (D-56, - # P11a step 0): the workspace must build and pass its tests on each, not - # only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays - # the Linux job's; these run the build and the tests. The job tells - # which OS a failure is on; neither is allowed to hide the other. - portable: - name: portable (${{ matrix.os }}) - strategy: - fail-fast: false - matrix: - include: - - os: macos-latest - experimental: false - - os: windows-latest - experimental: true - runs-on: ${{ matrix.os }} - continue-on-error: ${{ matrix.experimental }} - # Same disk discipline as above: no incremental caches, line tables only. - env: - CARGO_INCREMENTAL: "0" - CARGO_PROFILE_DEV_DEBUG: line-tables-only - CARGO_PROFILE_TEST_DEBUG: line-tables-only - steps: - - uses: actions/checkout@v4 - - name: Rust (the version mise.toml pins) - shell: bash - run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99 - - name: Room for the build - shell: bash - run: df -h . | tail -1 - - name: The server-only build (no viewer) - run: cargo build -p ledger-cli --no-default-features - - name: Everything compiles - run: cargo build --workspace --all-targets - - name: Tests - run: cargo test --workspace - - name: What the build used - if: always() - shell: bash - run: df -h . | tail -1 diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml new file mode 100644 index 0000000..e1be155 --- /dev/null +++ b/.github/workflows/portable.yml @@ -0,0 +1,55 @@ +# The portable builds (plan P11a step 0, D-56): the workspace builds and +# passes its tests on macOS and Windows, not only on Linux. A workflow of +# its own, not a job beside `check`: GitHub serves a job's log only when +# its whole run has completed, and `check` takes over an hour, so a macOS +# failure's reason was unreadable for that long while it sat in the same +# run. Here each OS's run completes on its own. +name: portable +on: + push: + pull_request: + workflow_dispatch: +concurrency: + group: portable-${{ github.ref }} + cancel-in-progress: true +jobs: + # The destination is one installation for macOS, Windows and Linux (D-56, + # P11a step 0): the workspace must build and pass its tests on each, not + # only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays + # the Linux job's; these run the build and the tests. The job tells + # which OS a failure is on; neither is allowed to hide the other. + portable: + name: portable (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + experimental: false + - os: windows-latest + experimental: true + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} + # Same disk discipline as above: no incremental caches, line tables only. + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only + steps: + - uses: actions/checkout@v4 + - name: Rust (the version mise.toml pins) + shell: bash + run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99 + - name: Room for the build + shell: bash + run: df -h . | tail -1 + - name: The server-only build (no viewer) + run: cargo build -p ledger-cli --no-default-features + - name: Everything compiles + run: cargo build --workspace --all-targets + - name: Tests + run: cargo test --workspace + - name: What the build used + if: always() + shell: bash + run: df -h . | tail -1 From 1da01c31234e68381a29add5b8fbb2dd27bac773 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 17:49:11 -0600 Subject: [PATCH 2/3] test(cli): the spec script's package export gives the audit override; macOS tests keep short paths GitHub's check on 62d42b94: the spec script now runs `books.audit` (P8), so the package gate is live when the script exports and the run is behind the head; four tests failed on `audit_required`. The script gives `without_audit_reason`, as a human would. The portable macOS job, with the server's log in hand: "the books' private folder's path is too long for its sockets". A Unix socket path holds 104 bytes; the runner's temp folder plus a test home plus `Library/Application Support/wanderbooks/run/` is longer. The job sets TMPDIR=/tmp, so every test home is short there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .github/workflows/portable.yml | 6 ++++++ crates/ledger-cli/tests/spec_mcp.rs | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index e1be155..774cf18 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -35,6 +35,12 @@ jobs: CARGO_INCREMENTAL: "0" CARGO_PROFILE_DEV_DEBUG: line-tables-only CARGO_PROFILE_TEST_DEBUG: line-tables-only + # macOS: a Unix socket path holds 104 bytes, and the runner's default + # temp folder (/var/folders/...) plus a test home plus + # `Library/Application Support/wanderbooks/run/` is longer than that; + # the server then refuses the root ("too long for its sockets"). The + # tests' temp homes go under /tmp there. Windows ignores TMPDIR. + TMPDIR: /tmp steps: - uses: actions/checkout@v4 - name: Rust (the version mise.toml pins) diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index d88ef2f..ce1beb6 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -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 From 74f1691f77e097abdc45829653407df862ba5b45 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 18:09:00 -0600 Subject: [PATCH 3/3] fix(store): a root too deep for a socket's address is served on macOS too A Unix socket's address holds about 104 bytes. Linux reached a deep root's sockets through /proc/self/fd; every other Unix refused the root ("too long for its sockets"), which the portable macOS job met with a test home under the runner's temp folder, and a Mac with a deep home would meet too. On those systems the socket path now goes through a short link of this account's, /tmp/wanderbooks-/, in a folder made 0700 and refused unless it is this account's own; a stale or foreign link in its place is replaced or refused. The server and its clients compute the same link, so nothing else changes. Tests (tests/socket_path.rs, every Unix): a deep root gets a path that fits and reaches the dir; the link is private, stable, and replaces a stale one of ours. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/src/root.rs | 82 +++++++++++++++++++++++- crates/ledger-store/tests/socket_path.rs | 68 ++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 crates/ledger-store/tests/socket_path.rs diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 0d0d9f9..eb19f4b 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -314,14 +314,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, } +/// A short path that reaches `dir`: the symbolic link +/// `/tmp/wanderbooks-/` → `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 { + 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 { @@ -342,7 +410,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`", diff --git a/crates/ledger-store/tests/socket_path.rs b/crates/ledger-store/tests/socket_path.rs new file mode 100644 index 0000000..9d944c6 --- /dev/null +++ b/crates/ledger-store/tests/socket_path.rs @@ -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() + ); +}