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
@@ -34,43 +34,3 @@ jobs:
|
|||||||
- name: What the build used
|
- name: What the build used
|
||||||
if: always()
|
if: always()
|
||||||
run: df -h / | tail -1
|
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
|
|
||||||
@@ -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
|
||||||
@@ -1302,15 +1302,20 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
"books.export",
|
"books.export",
|
||||||
json!({"format": "iif", "report": "general_journal", "as_of": "2026-12-31"}),
|
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(
|
m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false,
|
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(
|
m.ok(
|
||||||
"books.export_cpa_package",
|
"books.export_cpa_package",
|
||||||
json!({"as_of": "2026-12-31", "include_receipts": false,
|
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
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// this lives (it is good only in this process); on the other Unixes
|
||||||
/// long is refused, saying so plainly (no path named).
|
/// (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)]
|
#[derive(Debug)]
|
||||||
pub struct SocketPath {
|
pub struct SocketPath {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
_dir: Option<std::fs::File>,
|
_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 {
|
impl SocketPath {
|
||||||
/// The socket `name` in `dir`.
|
/// The socket `name` in `dir`.
|
||||||
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
|
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||||
@@ -349,7 +417,15 @@ impl SocketPath {
|
|||||||
_dir: Some(handle),
|
_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(
|
Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidInput,
|
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 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