diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1276013..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: false - 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..79295db --- /dev/null +++ b/.github/workflows/portable.yml @@ -0,0 +1,61 @@ +# 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: false + 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 + # 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) + 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/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 diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index c2c8f77..6a114b6 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -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, } +/// 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 { @@ -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`", 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() + ); +}