P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an agent session or a terminal holds them: a host process takes leases on a socket, starts the server (the hidden mode `wanderbooks server`) with a pipe on its input, and closes it 10 s after the last lease ends. No background service. One host even in a race (run/host.lock). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
168e5c9846
commit
a298d154b6
31 files changed
+11937
-112
No files matched your search
Generated
+415
-3
@@ -503,6 +503,29 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.9"
|
||||
@@ -1048,6 +1071,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -1176,6 +1208,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -1189,7 +1231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -1202,7 +1244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1591,6 +1633,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@@ -1805,6 +1853,20 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -2177,6 +2239,12 @@ dependencies = [
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@@ -2351,8 +2419,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2561,6 +2631,25 @@ dependencies = [
|
||||
"euclid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2918,7 +3007,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -2930,6 +3021,21 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -2949,9 +3055,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3656,10 +3764,16 @@ dependencies = [
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
"ledger-gui",
|
||||
"ledger-host",
|
||||
"ledger-mcp",
|
||||
"ledger-store",
|
||||
"ledger-vault",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
@@ -3716,6 +3830,23 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ledger-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ledger-core",
|
||||
"ledger-mcp",
|
||||
"ledger-store",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ledger-import"
|
||||
version = "0.1.0"
|
||||
@@ -3744,6 +3875,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"http-body-util",
|
||||
"jsonschema",
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
@@ -3761,6 +3893,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
"tower",
|
||||
"zeroize",
|
||||
@@ -3926,6 +4059,12 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.21.0"
|
||||
@@ -4041,6 +4180,12 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "mutate_once"
|
||||
version = "0.1.2"
|
||||
@@ -4608,6 +4753,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -5194,6 +5345,63 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.21",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -5291,6 +5499,15 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.5.0"
|
||||
@@ -5493,21 +5710,30 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -5545,6 +5771,20 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "3.5.1"
|
||||
@@ -5723,6 +5963,81 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -5774,6 +6089,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -5812,6 +6136,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -6287,6 +6634,12 @@ dependencies = [
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "svgtypes"
|
||||
version = "0.16.1"
|
||||
@@ -6360,6 +6713,27 @@ dependencies = [
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -6536,6 +6910,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -6552,6 +6927,16 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -6572,6 +6957,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
@@ -7295,6 +7681,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -7805,6 +8197,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
@@ -8108,6 +8509,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -8315,7 +8727,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -17,14 +17,20 @@ default = ["gui"]
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-host = { path = "../ledger-host" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
libc = "0.2"
|
||||
reqwest = "0.13"
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -394,11 +394,19 @@ fn print_usage() {
|
||||
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID");
|
||||
eprintln!(" wanderbooks mcp --book ID");
|
||||
eprintln!(" wanderbooks serve | close | doctor");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
|
||||
eprintln!(" setup make the books' private folder (only your account can open it)");
|
||||
eprintln!(" books list, start or adopt books (`books --help`)");
|
||||
eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)");
|
||||
eprintln!(" serve hold the books open until Ctrl-C");
|
||||
eprintln!(" close close the books now: everything holding them lets go");
|
||||
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
|
||||
eprintln!(" DIR the books directory (default: current directory)");
|
||||
eprintln!(" --force rewrite the scaffold in an existing books directory");
|
||||
eprintln!(" --no-start don't start the agent afterwards");
|
||||
@@ -847,7 +855,14 @@ fn run_setup(args: Vec<String>) -> i32 {
|
||||
look there (tests and unusual setups only)"
|
||||
);
|
||||
}
|
||||
let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
||||
// XDG_DATA_HOME is this account's, for this account's home: with
|
||||
// another home given (tests), it is never read, so it can't aim at
|
||||
// this account's real books.
|
||||
let xdg = if account.as_ref() == Some(&home) {
|
||||
env::var_os("XDG_DATA_HOME").map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
|
||||
Ok((_, report)) => {
|
||||
let what = if report.created {
|
||||
@@ -884,6 +899,62 @@ fn run_dev_stdio(args: Vec<String>) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks server`: the books server for the data root, on its
|
||||
/// local sockets (plan 3.9). The host starts it.
|
||||
#[cfg(unix)]
|
||||
fn run_server(args: Vec<String>) -> i32 {
|
||||
match ledger_mcp::local::run("wanderbooks server", args) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that open and hold the books (plan 3.9): the host, the
|
||||
/// helper, the agents' bridge, and the owner's commands.
|
||||
#[cfg(unix)]
|
||||
fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
||||
let prog = format!("wanderbooks {mode}");
|
||||
let result = match mode {
|
||||
"host" => ledger_host::host::run(&prog, args),
|
||||
"helper" => ledger_host::helper::run(&prog, args),
|
||||
"mcp" => ledger_host::bridge::run(&prog, args),
|
||||
"serve" => ledger_host::commands::serve(&prog, args),
|
||||
"close" => ledger_host::commands::close(&prog, args),
|
||||
"doctor" => ledger_host::commands::doctor(&prog, args),
|
||||
_ => ledger_host::commands::books(&prog, args),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that hold the books, where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
|
||||
eprintln!("error: opening the books this way comes to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
/// `wanderbooks server` where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_server(_args: Vec<String>) -> i32 {
|
||||
eprintln!("error: the books server's local sockets come to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The modes run in this process and take their own flags.
|
||||
let mut all = env::args().skip(1);
|
||||
@@ -891,6 +962,11 @@ fn main() {
|
||||
Some("gui") => std::process::exit(run_gui(all.collect())),
|
||||
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
|
||||
Some("setup") => std::process::exit(run_setup(all.collect())),
|
||||
Some("server") => std::process::exit(run_server(all.collect())),
|
||||
Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => {
|
||||
let mode = mode.to_string();
|
||||
std::process::exit(run_local(&mode, all.collect()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut dir: Option<PathBuf> = None;
|
||||
@@ -921,7 +997,11 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() {
|
||||
if let Some(
|
||||
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve"
|
||||
| "close" | "books" | "doctor"),
|
||||
) = cmd.as_deref()
|
||||
{
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -103,6 +103,14 @@ fn each_mode_names_itself_in_its_usage() {
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().args(["server", "--help"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("usage: wanderbooks server [--root DIR]"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().arg("--help").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
for line in [
|
||||
@@ -129,9 +137,9 @@ fn the_viewer_names_itself_in_its_usage() {
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui"]
|
||||
&["dev-stdio", "gui", "server"]
|
||||
} else {
|
||||
&["dev-stdio"]
|
||||
&["dev-stdio", "server"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
@@ -150,7 +158,7 @@ fn a_mode_refuses_what_it_does_not_know() {
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio"] {
|
||||
for mode in ["gui", "dev-stdio", "server"] {
|
||||
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
@@ -218,3 +226,65 @@ fn the_server_only_build_has_no_viewer() {
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Books in the data root are the books server's alone (plan 3.1): the
|
||||
/// test server refuses them, and the books server needs a root.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dev_stdio_refuses_books_in_the_private_folder() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-one-root-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
for inside in [
|
||||
root.join("books/acme"),
|
||||
root.clone(),
|
||||
root.join("books/a/b"),
|
||||
] {
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir"])
|
||||
.arg(&inside)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("only the books server opens"), "{err}");
|
||||
assert!(!inside.join("journal.jsonl").exists(), "{inside:?}");
|
||||
}
|
||||
assert!(!root.join("books/acme").exists());
|
||||
// A relative path, from inside the root.
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir", "acme"])
|
||||
.current_dir(root.join("books"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
assert!(!root.join("books/acme").exists());
|
||||
|
||||
// No root yet: the books server says to run setup.
|
||||
let empty = tempfile::Builder::new()
|
||||
.prefix("wb-one-noroot-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["server", "--root"])
|
||||
.arg(empty.path().join("none"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("run `wanderbooks setup` first"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -69,16 +69,20 @@ fn setup_makes_the_private_folder_and_never_names_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
fn a_root_elsewhere_is_noted_for_next_time() {
|
||||
// (XDG_DATA_HOME is honoured only for this account's own home, which
|
||||
// a test never touches: Root::setup's tests cover it.)
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.prefix("wb-setup-else-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let xdg = home.path().join("xdg");
|
||||
let elsewhere = home.path().join("elsewhere").join("wanderbooks");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &xdg)
|
||||
.arg("--root")
|
||||
.arg(&elsewhere)
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
@@ -86,13 +90,10 @@ fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
|
||||
assert_eq!(mode(&elsewhere), 0o700);
|
||||
let noted =
|
||||
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
|
||||
assert_eq!(
|
||||
noted.trim_end(),
|
||||
xdg.join("wanderbooks").display().to_string()
|
||||
);
|
||||
assert_eq!(noted.trim_end(), elsewhere.display().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,3 +123,32 @@ fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
|
||||
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// With another home given (tests), this account's XDG_DATA_HOME is never
|
||||
/// read: setup can't be aimed at this account's real books by it.
|
||||
#[test]
|
||||
fn another_home_ignores_this_accounts_data_home() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let elsewhere = home.path().join("elsewhere");
|
||||
std::fs::create_dir(&elsewhere).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &elsewhere)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
home.path()
|
||||
.join(".local/share/wanderbooks/.wanderbooks-root")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&elsewhere).unwrap().count(),
|
||||
0,
|
||||
"XDG_DATA_HOME was used"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ledger-host"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks: the books open while something holds them (the host, its leases, the helper and the agents' bridge)."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time", "process"] }
|
||||
reqwest = "0.13"
|
||||
base64 = "0.22"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,940 @@
|
||||
//! The owner's commands: `wanderbooks serve`, `close` and `books` (plan
|
||||
//! 3.9, 5.5).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: from a terminal the owner holds the books open, closes them, and
|
||||
//! makes, lists and adopts books, without ever naming or touching
|
||||
//! the books' private folder.
|
||||
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
|
||||
//! any command that needs the server, holds a lease; "Close the
|
||||
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
|
||||
//! never reads the books), 5.5 (adopt; preview and confirmation at
|
||||
//! the terminal).
|
||||
//! INVARIANTS:
|
||||
//! 1. A command that needs the server holds a lease while it runs, and
|
||||
//! lets go when it ends.
|
||||
//! 2. Nothing printed names the data root.
|
||||
//! 3. Adoption is previewed first and goes ahead only on a yes at the
|
||||
//! terminal, or --yes; a vault beside the books must be named (or
|
||||
//! --no-vault given): it is never silently left behind.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
|
||||
use crate::opener::{HoldError, Lease, Opener, Starter};
|
||||
use ledger_mcp::admin::{self, Request, Response};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long a command waits for the books' host to answer one question.
|
||||
const HOST_WAIT: Duration = Duration::from_secs(5);
|
||||
/// How long a command waits for the books server to list the books.
|
||||
const LIST_WAIT: Duration = Duration::from_secs(30);
|
||||
/// How long `close` waits to see the books closed.
|
||||
const CLOSE_WAIT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Common flags: `--root DIR` and the test knobs.
|
||||
#[derive(Default)]
|
||||
struct Common {
|
||||
root: Option<PathBuf>,
|
||||
knobs: Knobs,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Take a common flag (`Ok(true)`), or leave the argument.
|
||||
fn take(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
match arg {
|
||||
"--root" => {
|
||||
self.root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?));
|
||||
Ok(true)
|
||||
}
|
||||
FLAG => {
|
||||
self.knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exit(code: i32) -> Box<dyn std::error::Error> {
|
||||
Box::new(crate::Exit(code))
|
||||
}
|
||||
|
||||
/// Hold the books and wait until they are open (INVARIANT 1).
|
||||
async fn open(opener: &mut Opener) -> Result<Lease, HoldError> {
|
||||
let deadline = tokio::time::Instant::now() + opener.knobs().open_wait;
|
||||
let too_long = || -> HoldError {
|
||||
"the books didn't open in time: run `wanderbooks doctor` to see why".into()
|
||||
};
|
||||
loop {
|
||||
let mut lease = tokio::time::timeout_at(deadline, opener.hold())
|
||||
.await
|
||||
.map_err(|_| too_long())??;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, lease.next()).await {
|
||||
Err(_) => return Err(too_long()),
|
||||
Ok(Some(HostMsg::Ready)) => {
|
||||
opener.opened();
|
||||
return Ok(lease);
|
||||
}
|
||||
Ok(Some(HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
})) => {
|
||||
return Err(HoldError::ClosedByOwner);
|
||||
}
|
||||
Ok(Some(HostMsg::Ending {
|
||||
reason: Reason::Restarting | Reason::Closing,
|
||||
..
|
||||
})) => break,
|
||||
Ok(Some(HostMsg::Ending { .. })) | Ok(None) => {
|
||||
// The wait before the next try counts against the
|
||||
// deadline too.
|
||||
tokio::time::timeout_at(deadline, opener.failed())
|
||||
.await
|
||||
.map_err(|_| too_long())??;
|
||||
break;
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server's admin socket, holding the books open meanwhile.
|
||||
fn with_server(common: &Common, request: Request) -> Result<Response, String> {
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let outcome = rt.block_on(async {
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Cli,
|
||||
None,
|
||||
)?;
|
||||
let lease = open(&mut opener).await.map_err(|e| match e {
|
||||
HoldError::ClosedByOwner => {
|
||||
"the books were closed just now (Close the books): nothing was done".to_string()
|
||||
}
|
||||
HoldError::Failed(why) => why,
|
||||
})?;
|
||||
let socket = opener.run_dir().join(ledger_mcp::local::ADMIN_SOCKET);
|
||||
// A list is quick; a create or an adoption takes what it takes.
|
||||
let wait = matches!(request, Request::List).then_some(LIST_WAIT);
|
||||
let answer =
|
||||
tokio::task::spawn_blocking(move || admin::call_within(&socket, request, wait))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string());
|
||||
drop(lease);
|
||||
answer
|
||||
});
|
||||
// Past its deadline, the command ends: nothing pending holds it.
|
||||
rt.shutdown_timeout(Duration::from_secs(1));
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Where the books stand while `close` waits for them to close.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Closing {
|
||||
/// No host, and no server finishing: closed.
|
||||
Closed,
|
||||
/// A new host, opened by something since: closed, then opened again.
|
||||
Reopened,
|
||||
/// Still on their way (a server finishing, or a host not answering).
|
||||
Going,
|
||||
}
|
||||
|
||||
/// What the host's answer (`asked`) and the server's lock say.
|
||||
fn closing(
|
||||
asked: &Result<Option<HostMsg>, String>,
|
||||
server_lock_held: impl FnOnce() -> bool,
|
||||
) -> Closing {
|
||||
match asked {
|
||||
Ok(None) if server_lock_held() => Closing::Going,
|
||||
Ok(None) => Closing::Closed,
|
||||
Ok(Some(HostMsg::Leases { .. })) => Closing::Reopened,
|
||||
_ => Closing::Going,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `deadline` has passed at `now`.
|
||||
fn past(deadline: std::time::Instant, now: std::time::Instant) -> bool {
|
||||
now > deadline
|
||||
}
|
||||
|
||||
/// What `doctor` says of books no host holds.
|
||||
fn no_host(server_lock_held: bool) -> &'static str {
|
||||
if server_lock_held {
|
||||
"The books are closing: a books server is finishing its last call."
|
||||
} else {
|
||||
"The books are closed."
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks serve`: hold the books open until Ctrl-C.
|
||||
pub fn serve(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(" Opens the books and holds them open until Ctrl-C. They close 10 s");
|
||||
eprintln!(" after nothing else (the app, an agent session) holds them.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let outcome = rt.block_on(async {
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Serve,
|
||||
None,
|
||||
)?;
|
||||
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
|
||||
// serve is doing (opening, waiting to try again): none is missed.
|
||||
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
let mut signals = tokio::signal::unix::signal(kind)?;
|
||||
let stop = stop.clone();
|
||||
tokio::spawn(async move {
|
||||
while signals.recv().await.is_some() {
|
||||
stop.notify_one();
|
||||
}
|
||||
});
|
||||
}
|
||||
let let_go = || {
|
||||
println!("Let go of the books.");
|
||||
Ok(())
|
||||
};
|
||||
let mut said_open = false;
|
||||
loop {
|
||||
println!("Opening the books…");
|
||||
let opened = tokio::select! {
|
||||
opened = open(&mut opener) => opened,
|
||||
() = stop.notified() => return let_go(),
|
||||
};
|
||||
let mut lease = match opened {
|
||||
Ok(lease) => lease,
|
||||
Err(HoldError::ClosedByOwner) => {
|
||||
println!("The books were closed (Close the books).");
|
||||
return Ok(());
|
||||
}
|
||||
Err(HoldError::Failed(why)) => {
|
||||
eprintln!("error: {why}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
};
|
||||
if !said_open {
|
||||
println!(
|
||||
"The books are open. Press Ctrl-C to let go of them: they close when nothing else holds them."
|
||||
);
|
||||
said_open = true;
|
||||
} else {
|
||||
println!("The books are open again.");
|
||||
}
|
||||
let ended = tokio::select! {
|
||||
() = stop.notified() => None,
|
||||
msg = lease.next() => Some(msg),
|
||||
};
|
||||
match ended {
|
||||
None => {
|
||||
drop(lease);
|
||||
return let_go();
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
})) => {
|
||||
println!("The books were closed (Close the books).");
|
||||
return Ok(());
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::Restarting,
|
||||
..
|
||||
})) => {
|
||||
println!("The books are restarting (a new version)…");
|
||||
}
|
||||
Some(Some(HostMsg::Ending {
|
||||
reason: Reason::Closing,
|
||||
..
|
||||
})) => {}
|
||||
Some(_) => {
|
||||
println!("The books server stopped; opening the books again…");
|
||||
let waited = tokio::select! {
|
||||
waited = opener.failed() => waited,
|
||||
() = stop.notified() => return let_go(),
|
||||
};
|
||||
if let Err(why) = waited {
|
||||
eprintln!("error: {why}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// A holder that gave up isn't held back by work still pending.
|
||||
rt.shutdown_timeout(Duration::from_secs(1));
|
||||
outcome
|
||||
}
|
||||
|
||||
/// `wanderbooks close`: close the books now; every holder lets go.
|
||||
pub fn close(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(" Closes the books now: the app, agent sessions and `serve` let go.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
|
||||
let run = root.run_dir().map_err(|e| e.to_string())?;
|
||||
let asked = crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)
|
||||
.map_err(|why| {
|
||||
// Asked, and no answer: the host may yet close them.
|
||||
eprintln!(
|
||||
"error: {why}: the books may still close; `wanderbooks doctor` shows whether they did"
|
||||
);
|
||||
exit(1)
|
||||
})?;
|
||||
match asked {
|
||||
None => {
|
||||
println!("The books are already closed.");
|
||||
Ok(())
|
||||
}
|
||||
Some(HostMsg::Done { .. }) => {
|
||||
println!("Closing the books: everything holding them lets go…");
|
||||
let deadline = std::time::Instant::now() + CLOSE_WAIT;
|
||||
loop {
|
||||
let asked = crate::opener::ask_once_within(&run, Ask::Leases, HOST_WAIT);
|
||||
match closing(&asked, || crate::opener::server_lock_held(&run)) {
|
||||
Closing::Closed => break,
|
||||
Closing::Reopened => {
|
||||
println!("The books were closed; something has opened them again.");
|
||||
return Ok(());
|
||||
}
|
||||
Closing::Going => {}
|
||||
}
|
||||
if past(deadline, std::time::Instant::now()) {
|
||||
println!("They are still closing (a request is finishing).");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
println!("The books are closed.");
|
||||
Ok(())
|
||||
}
|
||||
Some(HostMsg::Refused(why)) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("error: the books' host answered oddly: {other:?}");
|
||||
Err(exit(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn books_usage(prog: &str) {
|
||||
eprintln!("usage: {prog} list");
|
||||
eprintln!(" {prog} create ID [--currency CODE]");
|
||||
eprintln!(
|
||||
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
|
||||
);
|
||||
eprintln!(" list the books, and whether each is open");
|
||||
eprintln!(" create start new, empty books (base currency USD unless given)");
|
||||
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
|
||||
}
|
||||
|
||||
/// `wanderbooks books list|create|adopt`.
|
||||
pub fn books(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut currency = "USD".to_string();
|
||||
let mut id: Option<String> = None;
|
||||
let mut toml: Option<PathBuf> = None;
|
||||
let mut config_json: Option<PathBuf> = None;
|
||||
let mut vault: Option<PathBuf> = None;
|
||||
let mut vault_key: Option<PathBuf> = None;
|
||||
let mut no_vault = false;
|
||||
let mut skip_unknown = false;
|
||||
let mut yes = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}"));
|
||||
match arg.as_str() {
|
||||
"--currency" => currency = value("a currency code")?,
|
||||
"--id" => id = Some(value("an id")?),
|
||||
"--toml" => toml = Some(PathBuf::from(value("a file")?)),
|
||||
"--config-json" => config_json = Some(PathBuf::from(value("a file")?)),
|
||||
"--vault" => vault = Some(PathBuf::from(value("a folder")?)),
|
||||
"--vault-key" => vault_key = Some(PathBuf::from(value("a file")?)),
|
||||
"--no-vault" => no_vault = true,
|
||||
"--skip-unknown" => skip_unknown = true,
|
||||
"--yes" => yes = true,
|
||||
"--help" | "-h" => {
|
||||
books_usage(prog);
|
||||
return Ok(());
|
||||
}
|
||||
s if s.starts_with('-') => {
|
||||
eprintln!("unknown flag: {s}");
|
||||
books_usage(prog);
|
||||
return Err(exit(2));
|
||||
}
|
||||
_ => words.push(arg),
|
||||
}
|
||||
}
|
||||
let words: Vec<&str> = words.iter().map(String::as_str).collect();
|
||||
let answer = match words.as_slice() {
|
||||
["list"] => with_server(&common, Request::List),
|
||||
["create", new] => with_server(
|
||||
&common,
|
||||
Request::Create {
|
||||
id: (*new).to_string(),
|
||||
currency: currency.clone(),
|
||||
},
|
||||
),
|
||||
["adopt", source] => {
|
||||
let Some(id) = id else {
|
||||
eprintln!("error: name the adopted books with --id ID");
|
||||
return Err(exit(2));
|
||||
};
|
||||
return adopt(
|
||||
&common,
|
||||
Path::new(source),
|
||||
id,
|
||||
AdoptFiles {
|
||||
toml,
|
||||
config_json,
|
||||
vault,
|
||||
vault_key,
|
||||
no_vault,
|
||||
},
|
||||
skip_unknown,
|
||||
yes,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
books_usage(prog);
|
||||
return Err(exit(2));
|
||||
}
|
||||
};
|
||||
match answer {
|
||||
Ok(Response::Books(books)) if books.is_empty() => {
|
||||
println!("No books yet: `wanderbooks books create ID` starts some.");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Books(books)) => {
|
||||
for b in books {
|
||||
match b.problem {
|
||||
None => println!("{} open", b.id),
|
||||
Some(why) => println!("{} didn't open: {why}", b.id),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Created { id, problem: None }) => {
|
||||
println!("Made new books \"{id}\" ({currency}).");
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Created {
|
||||
id,
|
||||
problem: Some(why),
|
||||
}) => {
|
||||
eprintln!(
|
||||
"error: new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
|
||||
);
|
||||
Err(exit(1))
|
||||
}
|
||||
Ok(Response::Refused(why)) | Err(why) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("error: the books server answered oddly: {other:?}");
|
||||
Err(exit(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The files an adoption brings in with the books.
|
||||
struct AdoptFiles {
|
||||
toml: Option<PathBuf>,
|
||||
config_json: Option<PathBuf>,
|
||||
vault: Option<PathBuf>,
|
||||
vault_key: Option<PathBuf>,
|
||||
no_vault: bool,
|
||||
}
|
||||
|
||||
/// A hash's first twelve characters, for people.
|
||||
fn short_hash(hash: &str) -> String {
|
||||
hash.chars().take(12).collect()
|
||||
}
|
||||
|
||||
/// Where a P1 books dir keeps its vault: `vault/` beside the data dir.
|
||||
fn vault_beside(source: &Path) -> Option<PathBuf> {
|
||||
let source = std::fs::canonicalize(source).ok()?;
|
||||
let vault = source.parent()?.join("vault");
|
||||
vault.is_dir().then_some(vault)
|
||||
}
|
||||
|
||||
/// Whether standard input is a terminal.
|
||||
fn at_a_terminal() -> bool {
|
||||
// SAFETY: isatty has no preconditions.
|
||||
unsafe { libc::isatty(0) == 1 }
|
||||
}
|
||||
|
||||
/// `wanderbooks books adopt` (INVARIANT 3).
|
||||
fn adopt(
|
||||
common: &Common,
|
||||
source: &Path,
|
||||
id: String,
|
||||
files: AdoptFiles,
|
||||
skip_unknown: bool,
|
||||
yes: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let fail = |why: String| -> Result<(), Box<dyn std::error::Error>> {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
};
|
||||
let shown = source.display().to_string();
|
||||
if files.no_vault && (files.vault.is_some() || files.vault_key.is_some()) {
|
||||
return fail("--no-vault and --vault can't both be given".into());
|
||||
}
|
||||
match (&files.vault, &files.vault_key) {
|
||||
(Some(_), None) => {
|
||||
return fail("give the vault's key too, with --vault-key FILE (often ~/.config/wanderbooks/vault.key)".into());
|
||||
}
|
||||
(None, Some(_)) => return fail("give the vault's folder too, with --vault DIR".into()),
|
||||
_ => {}
|
||||
}
|
||||
if files.vault.is_none()
|
||||
&& !files.no_vault
|
||||
&& let Some(beside) = vault_beside(source)
|
||||
{
|
||||
return fail(format!(
|
||||
"there is a TIN vault beside these books ({}): name it with --vault DIR and its key with --vault-key FILE, or give --no-vault to leave it behind",
|
||||
beside.display()
|
||||
));
|
||||
}
|
||||
let read = |path: &Option<PathBuf>| -> Result<Option<String>, String> {
|
||||
path.as_ref()
|
||||
.map(|p| {
|
||||
std::fs::read_to_string(p).map_err(|e| format!("can't read {}: {e}", p.display()))
|
||||
})
|
||||
.transpose()
|
||||
};
|
||||
let settings_toml = match read(&files.toml) {
|
||||
Ok(t) => t,
|
||||
Err(why) => return fail(why),
|
||||
};
|
||||
let config_json = match read(&files.config_json) {
|
||||
Ok(t) => t,
|
||||
Err(why) => return fail(why),
|
||||
};
|
||||
let source = std::path::absolute(source).unwrap_or_else(|_| source.to_path_buf());
|
||||
let request = |expect_head: Option<(u64, String)>| Request::Adopt {
|
||||
id: id.clone(),
|
||||
source: source.clone(),
|
||||
settings_toml: settings_toml.clone(),
|
||||
config_json: config_json.clone(),
|
||||
vault_dir: files.vault.clone(),
|
||||
vault_key: files.vault_key.clone(),
|
||||
confirmed: expect_head.is_some(),
|
||||
expect_head,
|
||||
skip_unknown,
|
||||
};
|
||||
let preview = match with_server(common, request(None)) {
|
||||
Ok(Response::Preview(p)) => p,
|
||||
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{shown}: {why}")),
|
||||
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
|
||||
};
|
||||
println!(
|
||||
"Adopting the books at {} as \"{id}\": through event {} ({}).",
|
||||
source.display(),
|
||||
preview.source_head.0,
|
||||
short_hash(&preview.source_head.1)
|
||||
);
|
||||
if preview.keeps_settings {
|
||||
println!("They keep their own settings.");
|
||||
if files.toml.is_some() {
|
||||
println!("(The settings file given isn't used.)");
|
||||
}
|
||||
} else if preview.settings.is_empty() {
|
||||
println!("No settings move in.");
|
||||
} else {
|
||||
println!("These settings move in from the settings file:");
|
||||
for s in &preview.settings {
|
||||
match &s.value {
|
||||
Some(v) => println!(" {} = {v}", s.key),
|
||||
None => println!(" {} (cleared)", s.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &preview.notes {
|
||||
println!(" note: {n}");
|
||||
}
|
||||
for u in &preview.unknown {
|
||||
println!(" not moved in (the books don't know it): {u}");
|
||||
}
|
||||
if !preview.comments.is_empty() {
|
||||
println!(
|
||||
"The settings file's comments (not settings: keep what matters as a convention or a decision):"
|
||||
);
|
||||
for c in &preview.comments {
|
||||
println!(" # {c}");
|
||||
}
|
||||
}
|
||||
match &files.vault {
|
||||
Some(v) => println!(
|
||||
"The TIN vault at {} is copied in with its key ({} records, each checked to open).",
|
||||
v.display(),
|
||||
preview.vault_records
|
||||
),
|
||||
None => println!("No TIN vault is copied."),
|
||||
}
|
||||
if !preview.unknown.is_empty() && !skip_unknown {
|
||||
return fail(
|
||||
"the settings file has entries the books don't know (above): take them out, or adopt with --skip-unknown to leave them behind".into(),
|
||||
);
|
||||
}
|
||||
println!("The original is left exactly as it is.");
|
||||
if !yes {
|
||||
if !at_a_terminal() {
|
||||
return fail("not adopted: confirm at a terminal, or run again with --yes".into());
|
||||
}
|
||||
print!("Adopt them? [y/N] ");
|
||||
use std::io::Write;
|
||||
std::io::stdout().flush()?;
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
||||
println!("Not adopted.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
match with_server(common, request(Some(preview.source_head.clone()))) {
|
||||
Ok(Response::Adopted(a)) => {
|
||||
for line in adopted_lines(&a) {
|
||||
println!("{line}");
|
||||
}
|
||||
if let Some(why) = a.problem {
|
||||
return fail(format!(
|
||||
"the adopted books didn't open: {why}. Closing the books and opening them again tries again."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Response::Refused(why)) | Err(why) => fail(format!("{shown}: {why}")),
|
||||
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What an adoption did, a line each.
|
||||
fn adopted_lines(a: &admin::Adopted) -> Vec<String> {
|
||||
let mut extra = String::new();
|
||||
if a.unnamed_blobs > 0 {
|
||||
extra.push_str(&format!(
|
||||
", {} the books don't name (kept)",
|
||||
a.unnamed_blobs
|
||||
));
|
||||
}
|
||||
if a.skipped_files > 0 {
|
||||
extra.push_str(&format!(
|
||||
", {} unfinished or hidden files left behind",
|
||||
a.skipped_files
|
||||
));
|
||||
}
|
||||
let mut lines = vec![format!(
|
||||
"Adopted as \"{}\": {} stored files copied, {} of them checked against the books{extra}.",
|
||||
a.id, a.blob_files, a.blobs_verified
|
||||
)];
|
||||
// A vault is adopted only with its key, so a copied key says a vault
|
||||
// came too (with its records, perhaps none).
|
||||
if a.key_copied {
|
||||
lines.push(format!("TIN vault: {} records and its key.", a.vault_files));
|
||||
}
|
||||
if a.settings_imported > 0 {
|
||||
lines.push(format!("{} settings moved in.", a.settings_imported));
|
||||
}
|
||||
lines.push(format!(
|
||||
"Head: event {} ({}).",
|
||||
a.head.0,
|
||||
short_hash(&a.head.1)
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
/// How long `doctor` waits for the books server to answer.
|
||||
const DOCTOR_WAIT: Duration = Duration::from_secs(5);
|
||||
/// How many of each log's last lines `doctor` shows.
|
||||
const DOCTOR_LINES: usize = 12;
|
||||
|
||||
/// The last `n` lines of a log in `run/`, with the root's path taken out.
|
||||
fn log_tail(run: &Path, name: &str, root: &Path, n: usize) -> Vec<String> {
|
||||
let Ok(text) = std::fs::read_to_string(run.join(name)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// The root as found, and as it really is (through any link).
|
||||
let real = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let spellings = [root.display().to_string(), real.display().to_string()];
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
lines[lines.len().saturating_sub(n)..]
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let l = spellings.iter().fold(l.to_string(), |l, s| {
|
||||
l.replace(s.as_str(), ledger_store::root::THE_ROOT)
|
||||
});
|
||||
// A log line can carry a panic's text: no full TIN is shown.
|
||||
ledger_core::parties::redact_full_tins(&l).into_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `wanderbooks doctor`: whether the books are open, who holds them, and
|
||||
/// what the host and the server last said. It names no folder.
|
||||
pub fn doctor(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog}");
|
||||
eprintln!(
|
||||
" Says whether the books are open, what holds them, and what went wrong."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
eprintln!("usage: {prog}");
|
||||
return Err(exit(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = match ledger_store::root::Root::find(common.root.as_deref()) {
|
||||
Ok(root) => root,
|
||||
Err(e) => {
|
||||
println!("The books' private folder: {e}");
|
||||
return Err(exit(1));
|
||||
}
|
||||
};
|
||||
println!("The books' private folder: there, and only your account can open it.");
|
||||
println!("Isolation: same_user (advisory): hidden from agents, not locked against them.");
|
||||
let run = root.run_dir().map_err(|e| e.to_string())?;
|
||||
match crate::opener::ask_once_within(&run, Ask::Leases, DOCTOR_WAIT) {
|
||||
Ok(None) => println!("{}", no_host(crate::opener::server_lock_held(&run))),
|
||||
Ok(Some(HostMsg::Leases { ready, leases })) => {
|
||||
if ready {
|
||||
println!("The books are open.");
|
||||
// Each book, and why one didn't open.
|
||||
let socket = run.join(ledger_mcp::local::ADMIN_SOCKET);
|
||||
// A quick look: a wedged server doesn't hang the doctor.
|
||||
let listed = admin::call_within(&socket, Request::List, Some(DOCTOR_WAIT));
|
||||
if let Err(why) = &listed {
|
||||
println!(" The books server doesn't answer: {why}");
|
||||
}
|
||||
if let Ok(admin::Response::Books(books)) = listed {
|
||||
for b in books {
|
||||
match b.problem {
|
||||
None => println!(" {} open", b.id),
|
||||
Some(why) => println!(" {} didn't open: {why}", b.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("The books are opening (the books server isn't ready yet).");
|
||||
}
|
||||
if leases.is_empty() {
|
||||
println!("Nothing holds them: they close soon.");
|
||||
} else {
|
||||
println!("Held by:");
|
||||
for l in leases {
|
||||
match l.book {
|
||||
Some(book) => println!(" {} (pid {}), on {book}", l.kind.label(), l.pid),
|
||||
None => println!(" {} (pid {})", l.kind.label(), l.pid),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(HostMsg::Ending { .. })) => println!("The books are closing."),
|
||||
Ok(Some(other)) => println!("The books' host answered oddly: {other:?}"),
|
||||
Err(why) => println!("The books' host can't be asked: {why}"),
|
||||
}
|
||||
for (name, what) in [
|
||||
(crate::start::HOST_LOG, "The host"),
|
||||
(crate::host::SERVER_LOG, "The books server"),
|
||||
] {
|
||||
let tail = log_tail(&run, name, root.path(), DOCTOR_LINES);
|
||||
if !tail.is_empty() {
|
||||
println!("{what}, last said:");
|
||||
for line in tail {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn close_reads_the_books_state_for_what_it_is() {
|
||||
let leases = Ok(Some(HostMsg::Leases {
|
||||
ready: true,
|
||||
leases: vec![],
|
||||
}));
|
||||
assert_eq!(closing(&Ok(None), || false), Closing::Closed);
|
||||
assert_eq!(
|
||||
closing(&Ok(None), || true),
|
||||
Closing::Going,
|
||||
"a server finishing"
|
||||
);
|
||||
assert_eq!(closing(&leases, || false), Closing::Reopened);
|
||||
assert_eq!(closing(&Err("no".into()), || false), Closing::Going);
|
||||
assert_eq!(closing(&Ok(Some(HostMsg::Ready)), || false), Closing::Going);
|
||||
let now = std::time::Instant::now();
|
||||
let later = now + Duration::from_secs(1);
|
||||
assert!(!past(later, now));
|
||||
assert!(!past(now, now), "not yet at the deadline itself");
|
||||
assert!(past(now, later));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_tells_closing_from_closed() {
|
||||
assert!(no_host(true).contains("closing"));
|
||||
assert_eq!(no_host(false), "The books are closed.");
|
||||
}
|
||||
|
||||
fn adopted() -> admin::Adopted {
|
||||
admin::Adopted {
|
||||
id: "old".into(),
|
||||
source_head: (3, "a".repeat(64)),
|
||||
head: (4, "0123456789abcdef".repeat(4)),
|
||||
blob_files: 5,
|
||||
blobs_verified: 4,
|
||||
unnamed_blobs: 0,
|
||||
skipped_files: 0,
|
||||
vault_files: 0,
|
||||
key_copied: false,
|
||||
settings_imported: 0,
|
||||
settings_ignored: false,
|
||||
source_lock_created: false,
|
||||
problem: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adoption_says_what_it_did_and_only_that() {
|
||||
let plain = adopted_lines(&adopted());
|
||||
assert_eq!(
|
||||
plain,
|
||||
[
|
||||
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books.",
|
||||
"Head: event 4 (0123456789ab).",
|
||||
]
|
||||
);
|
||||
let all = adopted_lines(&admin::Adopted {
|
||||
unnamed_blobs: 1,
|
||||
skipped_files: 2,
|
||||
vault_files: 3,
|
||||
key_copied: true,
|
||||
settings_imported: 6,
|
||||
..adopted()
|
||||
});
|
||||
assert_eq!(
|
||||
all,
|
||||
[
|
||||
"Adopted as \"old\": 5 stored files copied, 4 of them checked against the books, 1 the books don't name (kept), 2 unfinished or hidden files left behind.",
|
||||
"TIN vault: 3 records and its key.",
|
||||
"6 settings moved in.",
|
||||
"Head: event 4 (0123456789ab).",
|
||||
]
|
||||
);
|
||||
// A vault with no records still came, with its key.
|
||||
let empty = adopted_lines(&admin::Adopted {
|
||||
key_copied: true,
|
||||
..adopted()
|
||||
});
|
||||
assert!(empty.contains(&"TIN vault: 0 records and its key.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_lines_shown_name_no_folder_and_no_tin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("root");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let run = root.join("run");
|
||||
std::fs::create_dir(&run).unwrap();
|
||||
std::fs::write(
|
||||
run.join("server.log"),
|
||||
format!(
|
||||
"old line\nthread panicked at {}/books/acme: mid-write 241-40-4915\nlast\n",
|
||||
root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let tail = log_tail(&run, "server.log", &root, 2);
|
||||
assert_eq!(tail.len(), 2);
|
||||
let text = tail.join("\n");
|
||||
assert!(!text.contains(&root.display().to_string()), "{text}");
|
||||
assert!(!text.contains("241-40-4915"), "{text}");
|
||||
assert!(text.contains(ledger_store::root::THE_ROOT), "{text}");
|
||||
assert_eq!(tail[1], "last");
|
||||
assert!(log_tail(&run, "none.log", &root, 5).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! `wanderbooks helper`: the small unconfined process a confined mode
|
||||
//! keeps beside it (plan 3.9, "Starting the host cleanly").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
|
||||
//! start the host cleanly and hand off the few things a sandbox
|
||||
//! forbids, through a channel that can ask for nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
|
||||
//! socket pair before any thread, a fixed message set; it never
|
||||
//! accepts a path or a command line).
|
||||
//! INVARIANTS:
|
||||
//! 1. It talks only over the socket it was handed (descriptor 3), one
|
||||
//! JSON line each way, and exits when that closes.
|
||||
//! 2. It accepts exactly start_host, open_with (an attachment id),
|
||||
//! stage_backup and start_updater, none carrying a path or command;
|
||||
//! anything else is refused.
|
||||
//! 3. start_host starts the host cleanly (start.rs).
|
||||
//! 4. Spawned before the caller starts any thread.
|
||||
//! 5. A message is exactly its version and one ask, on a line of at
|
||||
//! most MAX_LINE bytes; anything else ends the helper.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// This protocol's version.
|
||||
pub const PROTO: u32 = 1;
|
||||
/// The descriptor the helper is handed its socket on.
|
||||
const FD: i32 = 3;
|
||||
/// The longest line either side sends.
|
||||
const MAX_LINE: u64 = 64 * 1024;
|
||||
|
||||
/// A message to the helper (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What it asks.
|
||||
#[serde(flatten)]
|
||||
pub ask: HelperAsk,
|
||||
}
|
||||
|
||||
/// What the helper can be asked: exactly these (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HelperAsk {
|
||||
/// Start the books' host.
|
||||
StartHost,
|
||||
/// Open a document with the system's app for it.
|
||||
OpenWith {
|
||||
/// The attachment's id.
|
||||
attachment: u64,
|
||||
},
|
||||
/// Ask the owner for a backup file and stage it for a restore.
|
||||
StageBackup,
|
||||
/// Start the updater.
|
||||
StartUpdater,
|
||||
}
|
||||
|
||||
/// The helper's answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Answer {
|
||||
/// Done.
|
||||
Done,
|
||||
/// Refused, with why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// The caller's end: a running helper.
|
||||
pub struct Client {
|
||||
stream: std::sync::Mutex<UnixStream>,
|
||||
child: std::sync::Mutex<std::process::Child>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Spawn a helper for the root (`root`, else this account's), with
|
||||
/// these knobs. Call it before starting any thread (INVARIANT 4).
|
||||
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
|
||||
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
|
||||
let exe = crate::start::this_executable()?;
|
||||
let mut c = std::process::Command::new(exe);
|
||||
c.arg("helper");
|
||||
if let Some(root) = root {
|
||||
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
|
||||
c.arg("--root").arg(found.path());
|
||||
}
|
||||
c.args(knobs.args())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null());
|
||||
let fd = theirs.as_raw_fd();
|
||||
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
|
||||
// its end of the pair as descriptor 3, without close-on-exec (which
|
||||
// a descriptor already numbered 3 would still have).
|
||||
unsafe {
|
||||
c.pre_exec(move || {
|
||||
let ok =
|
||||
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
|
||||
if !ok {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let child = c
|
||||
.spawn()
|
||||
.map_err(|e| format!("can't start the helper: {e}"))?;
|
||||
drop(theirs);
|
||||
Ok(Self {
|
||||
stream: std::sync::Mutex::new(ours),
|
||||
child: std::sync::Mutex::new(child),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the helper one thing (blocking).
|
||||
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut line =
|
||||
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
|
||||
line.push('\n');
|
||||
(&*stream)
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
|
||||
.read_line(&mut answer)
|
||||
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
|
||||
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
|
||||
}
|
||||
|
||||
/// Start the books' host (INVARIANT 3).
|
||||
pub fn start_host(&self) -> Result<(), String> {
|
||||
match self.ask(HelperAsk::StartHost)? {
|
||||
Answer::Done => Ok(()),
|
||||
Answer::Refused(why) => Err(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
// Closing our end ends the helper (INVARIANT 1); reap it.
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
drop(stream);
|
||||
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the helper does with one message.
|
||||
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
|
||||
match ask {
|
||||
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
|
||||
Ok(()) => Answer::Done,
|
||||
Err(why) => Answer::Refused(why),
|
||||
},
|
||||
HelperAsk::OpenWith { .. } => {
|
||||
Answer::Refused("opening documents comes with the viewer's menu".into())
|
||||
}
|
||||
HelperAsk::StageBackup => Answer::Refused("restoring isn't built yet".into()),
|
||||
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
|
||||
/// (INVARIANT 1).
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut knobs = Knobs::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
|
||||
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog} [--root DIR]");
|
||||
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
|
||||
// the peer check below refuses it.
|
||||
let stream = unsafe { UnixStream::from_raw_fd(FD) };
|
||||
// Taken: nothing this helper starts inherits it.
|
||||
// SAFETY: fcntl on our own descriptor.
|
||||
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
serve(std::io::BufReader::new(&stream), &stream, |ask| {
|
||||
answer(ask, root.as_deref(), &knobs)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer each message read from `reader` on `writer`, a line each, with
|
||||
/// `reply` for the asks of this version, until the input ends or a line
|
||||
/// is too long or cut off (INVARIANTS 1, 5).
|
||||
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
|
||||
loop {
|
||||
// INVARIANT 5: never more than a line's worth read.
|
||||
let mut line = String::new();
|
||||
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) if !line.ends_with('\n') => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let parsed = crate::lease::strict_pair(line.trim_end())
|
||||
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
|
||||
let answer = match parsed {
|
||||
Ok(m) if m.proto == PROTO => reply(m.ask),
|
||||
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
|
||||
Err(e) => Answer::Refused(format!("not understood: {e}")),
|
||||
};
|
||||
let mut text = serde_json::to_string(&answer).unwrap_or_default();
|
||||
text.push('\n');
|
||||
if writer.write_all(text.as_bytes()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn exactly_four_asks_and_none_carries_a_path() {
|
||||
for ok in [
|
||||
json!({"proto": 1, "start_host": null}),
|
||||
json!({"proto": 1, "open_with": {"attachment": 7}}),
|
||||
json!({"proto": 1, "stage_backup": null}),
|
||||
json!({"proto": 1, "start_updater": null}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(ok.clone()).is_ok(),
|
||||
"{ok}"
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
|
||||
json!({"proto": 1, "open_with": {"path": "/x"}}),
|
||||
json!({"proto": 1, "run": {"command": "sh"}}),
|
||||
json!({"proto": 1, "start_host": {"root": "/x"}}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(bad.clone()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// Two asks in one message: refused, not half-read.
|
||||
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
|
||||
assert!(crate::lease::strict_pair(two).is_err());
|
||||
}
|
||||
|
||||
/// Serve `input`; the answers, a line each.
|
||||
fn served(input: &[u8]) -> Vec<String> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
serve(input, &mut out, |ask| match ask {
|
||||
HelperAsk::StartHost => Answer::Done,
|
||||
_ => Answer::Refused("no".into()),
|
||||
});
|
||||
String::from_utf8(out)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_is_answered_and_a_bad_one_ends_it() {
|
||||
let ok = r#"{"proto":1,"start_host":null}"#;
|
||||
let other = r#"{"proto":2,"start_host":null}"#;
|
||||
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
|
||||
assert_eq!(got[0], r#""done""#);
|
||||
assert!(got[1].contains("another version"), "{}", got[1]);
|
||||
assert!(got[2].contains("not understood"), "{}", got[2]);
|
||||
assert_eq!(got[3], r#""done""#);
|
||||
assert_eq!(got.len(), 4);
|
||||
// A line at the limit is read; one byte more ends the helper,
|
||||
// and so does one cut off without its newline.
|
||||
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
|
||||
assert_eq!(served(at.as_bytes()), [r#""done""#]);
|
||||
let over = format!(
|
||||
"{ok}{}\n{ok}\n",
|
||||
" ".repeat(MAX_LINE as usize - ok.len() + 1)
|
||||
);
|
||||
assert!(served(over.as_bytes()).is_empty());
|
||||
assert!(served(ok.as_bytes()).is_empty());
|
||||
// An answer that can't be written ends it.
|
||||
struct Broken;
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let asked = std::cell::Cell::new(0);
|
||||
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
|
||||
asked.set(asked.get() + 1);
|
||||
Answer::Done
|
||||
});
|
||||
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_isnt_built_yet_is_refused() {
|
||||
let k = Knobs::default();
|
||||
for ask in [
|
||||
HelperAsk::OpenWith { attachment: 1 },
|
||||
HelperAsk::StageBackup,
|
||||
HelperAsk::StartUpdater,
|
||||
] {
|
||||
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,150 @@
|
||||
//! The host's and bridge's timings, which tests shorten (`--test-knobs`).
|
||||
//!
|
||||
//! Every mode that starts another passes its knobs on, so a test sets
|
||||
//! them once, on the holder it starts. Nothing else sets them: the
|
||||
//! defaults are the plan's (3.9).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The timings in force.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Knobs {
|
||||
/// How long the host waits after the last lease ends before it
|
||||
/// closes the books (plan: 10 s).
|
||||
pub close_after: Duration,
|
||||
/// How often the host looks whether its executable was replaced.
|
||||
pub exe_poll: Duration,
|
||||
/// The first wait before a holder starts a failed host again; it
|
||||
/// doubles each time (plan: 1, 2, 4 … 60 s, five tries).
|
||||
pub backoff: Duration,
|
||||
/// How long the host waits before starting the server (tests of a
|
||||
/// slow start only; 0 otherwise).
|
||||
pub server_delay: Duration,
|
||||
/// How long the bridge waits for the server before answering
|
||||
/// `initialize` itself (plan: 5 s).
|
||||
pub init_wait: Duration,
|
||||
/// How long the bridge holds a call while the books open (plan: 60 s).
|
||||
pub queue_wait: Duration,
|
||||
/// How long a command waits for the books to open.
|
||||
pub open_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for Knobs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
close_after: Duration::from_secs(10),
|
||||
exe_poll: Duration::from_secs(5),
|
||||
backoff: Duration::from_secs(1),
|
||||
server_delay: Duration::ZERO,
|
||||
init_wait: Duration::from_secs(5),
|
||||
queue_wait: Duration::from_secs(60),
|
||||
open_wait: Duration::from_secs(120),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The flag that carries them.
|
||||
pub const FLAG: &str = "--test-knobs";
|
||||
|
||||
impl Knobs {
|
||||
/// Read `name=millis,...` (any subset; the rest stay default).
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut k = Self::default();
|
||||
for pair in text.split(',').filter(|p| !p.is_empty()) {
|
||||
let (name, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("{FLAG}: {pair:?} isn't name=milliseconds"))?;
|
||||
let ms: u64 = value
|
||||
.parse()
|
||||
.map_err(|_| format!("{FLAG}: {value:?} isn't a number of milliseconds"))?;
|
||||
let d = Duration::from_millis(ms);
|
||||
match name {
|
||||
"close_after" => k.close_after = d,
|
||||
"exe_poll" => k.exe_poll = d,
|
||||
"backoff" => k.backoff = d,
|
||||
"server_delay" => k.server_delay = d,
|
||||
"init_wait" => k.init_wait = d,
|
||||
"queue_wait" => k.queue_wait = d,
|
||||
"open_wait" => k.open_wait = d,
|
||||
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
|
||||
}
|
||||
}
|
||||
Ok(k)
|
||||
}
|
||||
|
||||
/// The flag's value that gives these knobs, or `None` for the
|
||||
/// defaults (then nothing is passed on).
|
||||
pub fn to_arg(&self) -> Option<String> {
|
||||
let d = Self::default();
|
||||
let mut parts = Vec::new();
|
||||
for (name, now, default) in [
|
||||
("close_after", self.close_after, d.close_after),
|
||||
("exe_poll", self.exe_poll, d.exe_poll),
|
||||
("backoff", self.backoff, d.backoff),
|
||||
("server_delay", self.server_delay, d.server_delay),
|
||||
("init_wait", self.init_wait, d.init_wait),
|
||||
("queue_wait", self.queue_wait, d.queue_wait),
|
||||
("open_wait", self.open_wait, d.open_wait),
|
||||
] {
|
||||
if now != default {
|
||||
parts.push(format!("{name}={}", now.as_millis()));
|
||||
}
|
||||
}
|
||||
(!parts.is_empty()).then(|| parts.join(","))
|
||||
}
|
||||
|
||||
/// The arguments that pass these knobs on to another mode.
|
||||
pub fn args(&self) -> Vec<String> {
|
||||
match self.to_arg() {
|
||||
Some(v) => vec![FLAG.to_string(), v],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_defaults_are_the_plans() {
|
||||
let k = Knobs::default();
|
||||
assert_eq!(k.close_after, Duration::from_secs(10));
|
||||
assert_eq!(k.backoff, Duration::from_secs(1));
|
||||
assert_eq!(k.init_wait, Duration::from_secs(5));
|
||||
assert_eq!(k.queue_wait, Duration::from_secs(60));
|
||||
assert_eq!(k.exe_poll, Duration::from_secs(5));
|
||||
assert_eq!(k.server_delay, Duration::ZERO);
|
||||
assert_eq!(k.open_wait, Duration::from_secs(120));
|
||||
assert_eq!(k.to_arg(), None);
|
||||
assert!(k.args().is_empty());
|
||||
assert_eq!(Knobs::parse("").unwrap(), k);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knobs_pass_on_exactly() {
|
||||
let k = Knobs::parse(
|
||||
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(k.close_after, Duration::from_millis(500));
|
||||
assert_eq!(k.backoff, Duration::from_millis(20));
|
||||
assert_eq!(k.server_delay, Duration::from_millis(3000));
|
||||
assert_eq!(k.init_wait, Duration::from_millis(100));
|
||||
assert_eq!(k.queue_wait, Duration::from_millis(900));
|
||||
assert_eq!(k.exe_poll, Duration::from_millis(50));
|
||||
assert_eq!(k.open_wait, Duration::from_millis(70));
|
||||
let args = k.args();
|
||||
assert_eq!(args[0], FLAG);
|
||||
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
|
||||
let one = Knobs::parse("close_after=1").unwrap();
|
||||
assert_eq!(one.to_arg().as_deref(), Some("close_after=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_knob_is_refused() {
|
||||
for bad in ["close_after", "close_after=x", "nope=1", "close_after=-1"] {
|
||||
assert!(Knobs::parse(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! The lease socket's messages (`run/lease.sock`, plan 3.9).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: holders hold the books open, and the owner's few requests reach
|
||||
//! the server, through one socket that accepts nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
|
||||
//! leases record their kind and PID; exactly five owner intents;
|
||||
//! consent and a replacing restore carry the owner passphrase;
|
||||
//! "Closing": the host says why on each lease; protocol versions N
|
||||
//! and N-1).
|
||||
//! INVARIANTS:
|
||||
//! 1. A connection's first line is one Ask; a Hold keeps the
|
||||
//! connection (the lease lasts until it closes), anything else is
|
||||
//! answered once and the connection closed.
|
||||
//! 2. Exactly five intents exist: close, back_up_now, new_books,
|
||||
//! restore (a staged backup's id, never a path) and
|
||||
//! consent_format_upgrade; any other request is refused.
|
||||
//! 3. consent_format_upgrade, and a restore that replaces a book, carry
|
||||
//! the owner passphrase; without one they are refused before
|
||||
//! anything is done.
|
||||
//! 4. Before the host ends a lease it sends the reason; a lease that
|
||||
//! ends with no reason means the host is gone.
|
||||
//! 5. Each side accepts its own protocol version and the one before.
|
||||
//! 6. Lines are at most MAX_LINE bytes.
|
||||
//! 7. A first line is exactly its version and one ask: anything more
|
||||
//! (two asks, an unknown key) is refused, never half-read.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// This protocol's version (INVARIANT 5).
|
||||
pub const PROTO: u32 = 1;
|
||||
/// The oldest version still accepted (there is none before the first).
|
||||
pub const OLDEST: u32 = 1;
|
||||
/// The host's socket, in the root's `run/`.
|
||||
pub const LEASE_SOCKET: &str = "lease.sock";
|
||||
/// Held by the one host for a root.
|
||||
pub const HOST_LOCK: &str = "host.lock";
|
||||
/// Held while the host restarts; openers wait on it.
|
||||
pub const UPDATE_LOCK: &str = "update.lock";
|
||||
/// The longest line either side sends (INVARIANT 6).
|
||||
pub const MAX_LINE: usize = 64 * 1024;
|
||||
|
||||
/// Whether a message of protocol version `proto` is accepted.
|
||||
pub fn accepts(proto: u32) -> bool {
|
||||
(OLDEST..=PROTO).contains(&proto)
|
||||
}
|
||||
|
||||
/// What kind of holder a lease is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
/// The app (the viewer).
|
||||
App,
|
||||
/// An agent session's bridge (`wanderbooks mcp`).
|
||||
Bridge,
|
||||
/// A terminal running `wanderbooks serve`.
|
||||
Serve,
|
||||
/// A command that needs the server (`wanderbooks books ...`).
|
||||
Cli,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// How a person names it.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Kind::App => "the app",
|
||||
Kind::Bridge => "an agent session",
|
||||
Kind::Serve => "wanderbooks serve",
|
||||
Kind::Cli => "a wanderbooks command",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection's first line (INVARIANT 1).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What it asks.
|
||||
#[serde(flatten)]
|
||||
pub ask: Ask,
|
||||
}
|
||||
|
||||
/// What a connection asks.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Ask {
|
||||
/// Hold the books open while this connection lasts.
|
||||
Hold {
|
||||
/// Who holds them.
|
||||
kind: Kind,
|
||||
/// The holder's process.
|
||||
pid: u32,
|
||||
/// The book it works on, if one.
|
||||
#[serde(default)]
|
||||
book: Option<String>,
|
||||
},
|
||||
/// The leases held now (the app and `wanderbooks doctor` list them).
|
||||
Leases,
|
||||
/// One of the owner's intents.
|
||||
Intent(Intent),
|
||||
}
|
||||
|
||||
/// The owner's intents: exactly these five (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Intent {
|
||||
/// Close the books now: every lease ends.
|
||||
Close,
|
||||
/// Back a book up now.
|
||||
BackUpNow {
|
||||
/// Which.
|
||||
book: String,
|
||||
},
|
||||
/// Start new books.
|
||||
NewBooks {
|
||||
/// Their id.
|
||||
id: String,
|
||||
/// Their base currency.
|
||||
currency: String,
|
||||
},
|
||||
/// Restore a staged backup, as its own book or replacing one.
|
||||
Restore {
|
||||
/// The staged backup's id (never a path).
|
||||
staged: String,
|
||||
/// The book it replaces, if it replaces one.
|
||||
#[serde(default)]
|
||||
replace: Option<String>,
|
||||
/// The owner passphrase: needed to replace (INVARIANT 3).
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
/// Let the server upgrade a book's journal format.
|
||||
ConsentFormatUpgrade {
|
||||
/// Which.
|
||||
book: String,
|
||||
/// The owner passphrase (INVARIANT 3).
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Refuse an intent that needs the passphrase and has none
|
||||
/// (INVARIANT 3).
|
||||
pub fn check_passphrase(&self) -> Result<(), String> {
|
||||
let missing = |p: &Option<String>| p.as_deref().is_none_or(|p| p.is_empty());
|
||||
match self {
|
||||
Intent::ConsentFormatUpgrade { passphrase, .. } if missing(passphrase) => Err(
|
||||
"consenting to a format upgrade needs the owner passphrase, typed in the app"
|
||||
.into(),
|
||||
),
|
||||
Intent::Restore {
|
||||
replace: Some(_),
|
||||
passphrase,
|
||||
..
|
||||
} if missing(passphrase) => Err(
|
||||
"a restore that replaces books needs the owner passphrase, typed in the app".into(),
|
||||
),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the host ends a lease (INVARIANT 4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Reason {
|
||||
/// The owner closed the books ("Close the books", `wanderbooks
|
||||
/// close`): reopen only in a new session.
|
||||
ClosedByOwner,
|
||||
/// The host is restarting (an update): hold again once it is back.
|
||||
Restarting,
|
||||
/// The host is closing because nothing held it, or its restart or
|
||||
/// start went wrong (never the owner's close, which is
|
||||
/// `ClosedByOwner`): hold again, and a new host will answer.
|
||||
Closing,
|
||||
/// The books server stopped or couldn't start.
|
||||
ServerFailed,
|
||||
}
|
||||
|
||||
/// One lease, as listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeaseInfo {
|
||||
/// Its number.
|
||||
pub id: u64,
|
||||
/// Who holds it.
|
||||
pub kind: Kind,
|
||||
/// The holder's process.
|
||||
pub pid: u32,
|
||||
/// The book it works on, if one.
|
||||
#[serde(default)]
|
||||
pub book: Option<String>,
|
||||
}
|
||||
|
||||
/// What the host sends.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HostMsg {
|
||||
/// The lease is held; the books are open or opening.
|
||||
Held {
|
||||
/// The lease's number.
|
||||
lease: u64,
|
||||
},
|
||||
/// The books server is ready.
|
||||
Ready,
|
||||
/// The lease ends, and why (INVARIANT 4).
|
||||
Ending {
|
||||
/// Why.
|
||||
reason: Reason,
|
||||
/// More, in plain words (no path).
|
||||
#[serde(default)]
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// An intent was carried out.
|
||||
Done {
|
||||
/// What came of it, in plain words.
|
||||
#[serde(default)]
|
||||
detail: Option<String>,
|
||||
},
|
||||
/// An intent was sent on, and no answer came back: it may or may not
|
||||
/// have been carried out, in plain words.
|
||||
Uncertain(String),
|
||||
/// The leases held now, and whether the books are open yet.
|
||||
Leases {
|
||||
/// The books server is ready.
|
||||
ready: bool,
|
||||
/// The leases.
|
||||
leases: Vec<LeaseInfo>,
|
||||
},
|
||||
/// Refused, with why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// A connection's first line, read strictly (INVARIANT 7).
|
||||
pub fn parse_hello(text: &str) -> Result<Hello, String> {
|
||||
strict_pair(text).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// `text` as a JSON object of exactly two keys, `proto` and one more.
|
||||
pub(crate) fn strict_pair(text: &str) -> Result<serde_json::Value, String> {
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
match value.as_object() {
|
||||
Some(o) if o.len() == 2 && o.contains_key("proto") => Ok(value),
|
||||
Some(_) => Err("a message is its protocol version and exactly one request".into()),
|
||||
None => Err("a message is a JSON object".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One line: the message as JSON and a newline.
|
||||
pub fn line<T: Serialize>(message: &T) -> String {
|
||||
let mut text = serde_json::to_string(message).expect("a lease message is plain data");
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse(v: serde_json::Value) -> Result<Hello, serde_json::Error> {
|
||||
serde_json::from_value(v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_names_its_kind_pid_and_book() {
|
||||
let h = parse(json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
h.ask,
|
||||
Ask::Hold {
|
||||
kind: Kind::Bridge,
|
||||
pid: 7,
|
||||
book: Some("acme".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&h).unwrap(),
|
||||
json!({"proto": 1, "hold": {"kind": "bridge", "pid": 7, "book": "acme"}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_five_intents_are_understood() {
|
||||
for intent in [
|
||||
json!("close"),
|
||||
json!({"back_up_now": {"book": "acme"}}),
|
||||
json!({"new_books": {"id": "acme", "currency": "USD"}}),
|
||||
json!({"restore": {"staged": "s1"}}),
|
||||
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
|
||||
] {
|
||||
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
|
||||
assert!(matches!(h.ask, Ask::Intent(_)), "{intent}");
|
||||
}
|
||||
for bad in [
|
||||
json!({"proto": 1, "intent": "delete"}),
|
||||
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
|
||||
json!({"proto": 1, "exec": "sh"}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(parse(bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hello_is_exactly_its_version_and_one_ask() {
|
||||
assert!(parse_hello(r#"{"proto":1,"leases":null}"#).is_ok());
|
||||
for bad in [
|
||||
r#"{"proto":1,"intent":{"back_up_now":{"book":"a"}},"leases":null}"#,
|
||||
r#"{"proto":1,"leases":null,"extra":1}"#,
|
||||
r#"{"leases":null,"intent":"close"}"#,
|
||||
r#"{"proto":1}"#,
|
||||
r#"[1]"#,
|
||||
"not json",
|
||||
] {
|
||||
assert!(parse_hello(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_and_a_replacing_restore_need_the_passphrase() {
|
||||
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
|
||||
book: "acme".into(),
|
||||
passphrase: p.map(str::to_string),
|
||||
};
|
||||
assert!(consent(None).check_passphrase().is_err());
|
||||
assert!(consent(Some("")).check_passphrase().is_err());
|
||||
assert!(consent(Some("pw")).check_passphrase().is_ok());
|
||||
let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore {
|
||||
staged: "s1".into(),
|
||||
replace: replace.map(str::to_string),
|
||||
passphrase: p.map(str::to_string),
|
||||
};
|
||||
assert!(restore(None, None).check_passphrase().is_ok());
|
||||
assert!(restore(Some("acme"), None).check_passphrase().is_err());
|
||||
assert!(restore(Some("acme"), Some("")).check_passphrase().is_err());
|
||||
assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok());
|
||||
for other in [
|
||||
Intent::Close,
|
||||
Intent::BackUpNow { book: "a".into() },
|
||||
Intent::NewBooks {
|
||||
id: "a".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
] {
|
||||
assert!(other.check_passphrase().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_this_one_and_the_one_before() {
|
||||
assert!(accepts(PROTO));
|
||||
assert!(!accepts(PROTO + 1));
|
||||
assert!(!accepts(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_messages_round_trip_as_one_line() {
|
||||
for m in [
|
||||
HostMsg::Held { lease: 3 },
|
||||
HostMsg::Ready,
|
||||
HostMsg::Ending {
|
||||
reason: Reason::Restarting,
|
||||
detail: None,
|
||||
},
|
||||
HostMsg::Done {
|
||||
detail: Some("closed".into()),
|
||||
},
|
||||
HostMsg::Leases {
|
||||
ready: true,
|
||||
leases: vec![LeaseInfo {
|
||||
id: 1,
|
||||
kind: Kind::Serve,
|
||||
pid: 9,
|
||||
book: None,
|
||||
}],
|
||||
},
|
||||
HostMsg::Refused("no".into()),
|
||||
HostMsg::Uncertain("maybe".into()),
|
||||
] {
|
||||
let text = line(&m);
|
||||
assert!(text.ends_with('\n') && !text[..text.len() - 1].contains('\n'));
|
||||
assert_eq!(serde_json::from_str::<HostMsg>(text.trim_end()).unwrap(), m);
|
||||
}
|
||||
assert_eq!(Kind::Bridge.label(), "an agent session");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The books open while something holds them (plan 3.9, D-21, D-23).
|
||||
//!
|
||||
//! Nothing runs in the background: the first holder (the app, an agent's
|
||||
//! bridge, `wanderbooks serve`, or a command that needs the books) starts
|
||||
//! the **host**, which starts the books server and holds it open while
|
||||
//! any lease on its socket lasts, and closes it 10 s after the last one
|
||||
//! ends. Each part is a mode of the one `wanderbooks` executable:
|
||||
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
|
||||
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
|
||||
//! [`commands`] (`serve`, `close`, `books`).
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod bridge;
|
||||
#[cfg(unix)]
|
||||
pub mod commands;
|
||||
#[cfg(unix)]
|
||||
pub mod helper;
|
||||
#[cfg(unix)]
|
||||
pub mod host;
|
||||
pub mod knobs;
|
||||
pub mod lease;
|
||||
#[cfg(unix)]
|
||||
pub mod opener;
|
||||
#[cfg(unix)]
|
||||
mod start;
|
||||
|
||||
/// How a mode stops when it can't go on: the reason is already printed,
|
||||
/// and the number is the process's exit code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "exit code {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Exit {}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Holding the books: how every holder takes its lease (plan 3.9).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a holder gets its lease on the one host, starting one when none
|
||||
//! answers, even when several holders start at once; across a
|
||||
//! restart it simply holds again; a host that keeps failing is
|
||||
//! given up on, plainly, instead of retried forever.
|
||||
//! SOURCE: plan/served.md 3.9 ("One host, even in a race", "Closing":
|
||||
//! backoff 1, 2, 4 … 60 s, at most five tries; "Restarting":
|
||||
//! openers wait on update.lock); tests host_respawns,
|
||||
//! respawn_backs_off.
|
||||
//! INVARIANTS:
|
||||
//! 1. Before each try the holder waits while a restart holds
|
||||
//! run/update.lock.
|
||||
//! 2. No host answering: it starts one (its Starter) and keeps trying;
|
||||
//! a host that is closing or restarting turns it away and it tries
|
||||
//! again until a host takes the lease.
|
||||
//! 3. A start that brings no lease within START_WAIT (or whose host
|
||||
//! died before taking its lock), a host that doesn't answer, and a
|
||||
//! lease that ends with server_failed or with no reason at all, are
|
||||
//! failures: the holder tries again after knobs.backoff, doubling
|
||||
//! each time (at most 60 s), MAX_TRIES times; the failure after that
|
||||
//! gives up, naming `wanderbooks doctor`. Books that stayed open for
|
||||
//! STABLE clear the count; a server that fails soon after opening
|
||||
//! doesn't.
|
||||
//! 4. The host's end of the socket must be this account.
|
||||
//! 5. A holder that has held before, turned away because the owner
|
||||
//! closed the books, stays closed (HoldError::ClosedByOwner); a new
|
||||
//! holder waits for a new host.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::Knobs;
|
||||
use crate::lease::{self, Ask, Hello, HostMsg, Kind, Reason};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// How long a started host has to take a lease (longer than a new host
|
||||
/// waits for an old one's lock).
|
||||
const START_WAIT: Duration = Duration::from_secs(45);
|
||||
/// How long a started host has to take its lock: after that, no host
|
||||
/// holding it means the one started has died.
|
||||
const LOCK_WAIT: Duration = Duration::from_secs(2);
|
||||
/// How long the host has to answer a hello.
|
||||
const ANSWER_WAIT: Duration = Duration::from_secs(10);
|
||||
/// Tries after a failure before giving up (INVARIANT 3).
|
||||
pub const MAX_TRIES: u32 = 5;
|
||||
/// How long the books must stay open for earlier failures to be
|
||||
/// forgotten (INVARIANT 3).
|
||||
const STABLE: Duration = Duration::from_secs(60);
|
||||
/// The longest wait between tries.
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
|
||||
/// What a holder says when the host keeps failing.
|
||||
pub const KEEPS_FAILING: &str =
|
||||
"the books server keeps failing to start: run `wanderbooks doctor` to see why";
|
||||
|
||||
/// Why no lease was taken.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HoldError {
|
||||
/// The owner closed the books, and this holder had held them
|
||||
/// (INVARIANT 5).
|
||||
ClosedByOwner,
|
||||
/// Anything else, in plain words.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HoldError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HoldError::ClosedByOwner => write!(f, "the owner closed the books"),
|
||||
HoldError::Failed(why) => f.write_str(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for HoldError {
|
||||
fn from(why: String) -> Self {
|
||||
HoldError::Failed(why)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for HoldError {
|
||||
fn from(why: &str) -> Self {
|
||||
HoldError::Failed(why.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// How a holder starts a host when none answers.
|
||||
pub enum Starter {
|
||||
/// Start it itself (a holder that isn't confined).
|
||||
Direct,
|
||||
/// Ask its helper to (a confined holder).
|
||||
Helper(std::sync::Arc<crate::helper::Client>),
|
||||
}
|
||||
|
||||
/// Takes leases for one holder.
|
||||
pub struct Opener {
|
||||
root: Option<PathBuf>,
|
||||
run: PathBuf,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
failures: u32,
|
||||
/// When the books last opened on a lease.
|
||||
opened_at: Option<std::time::Instant>,
|
||||
/// Whether this holder has held a lease (INVARIANT 5).
|
||||
has_held: bool,
|
||||
}
|
||||
|
||||
/// A held lease: the books stay open while it lives.
|
||||
pub struct Lease {
|
||||
/// Its number.
|
||||
pub id: u64,
|
||||
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
|
||||
_write: tokio::net::unix::OwnedWriteHalf,
|
||||
}
|
||||
|
||||
impl Lease {
|
||||
/// The host's next word: `Ready`, or `Ending` with why; `None` when
|
||||
/// the host is gone without a word.
|
||||
pub async fn next(&mut self) -> Option<HostMsg> {
|
||||
loop {
|
||||
match self.lines.next_line().await {
|
||||
Ok(Some(line)) => match serde_json::from_str::<HostMsg>(&line) {
|
||||
Ok(msg) => return Some(msg),
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opener {
|
||||
/// An opener for the root (`root`, else the one this account's host
|
||||
/// finds), holding as `kind` for `book`.
|
||||
pub fn new(
|
||||
root: Option<&Path>,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
) -> Result<Self, String> {
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
root: root.map(|_| found.path().to_path_buf()),
|
||||
run,
|
||||
knobs,
|
||||
starter,
|
||||
kind,
|
||||
book,
|
||||
failures: 0,
|
||||
opened_at: None,
|
||||
has_held: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The timings in force.
|
||||
pub fn knobs(&self) -> Knobs {
|
||||
self.knobs
|
||||
}
|
||||
|
||||
/// The root's `run/` folder.
|
||||
pub fn run_dir(&self) -> &Path {
|
||||
&self.run
|
||||
}
|
||||
|
||||
/// The books opened on a lease (INVARIANT 3).
|
||||
pub fn opened(&mut self) {
|
||||
self.opened_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// Count a failure and wait before the next try, or give up
|
||||
/// (INVARIANT 3).
|
||||
pub async fn failed(&mut self) -> Result<(), String> {
|
||||
if self
|
||||
.opened_at
|
||||
.take()
|
||||
.is_some_and(|at| at.elapsed() >= STABLE)
|
||||
{
|
||||
self.failures = 0;
|
||||
}
|
||||
self.failures += 1;
|
||||
if self.failures > MAX_TRIES {
|
||||
return Err(KEEPS_FAILING.into());
|
||||
}
|
||||
tokio::time::sleep(backoff(self.knobs.backoff, self.failures)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
|
||||
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
|
||||
let mut started: Option<Instant> = None;
|
||||
loop {
|
||||
wait_for_restart(&self.run).await;
|
||||
let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
match tokio::net::UnixStream::connect(short.path()).await {
|
||||
Ok(stream) => {
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
return Err(
|
||||
"the books' host socket belongs to another account: not used".into(),
|
||||
);
|
||||
}
|
||||
match self.ask(stream).await? {
|
||||
Asked::Held(lease) => {
|
||||
self.has_held = true;
|
||||
return Ok(lease);
|
||||
}
|
||||
Asked::OwnerClosed if self.has_held => {
|
||||
return Err(HoldError::ClosedByOwner);
|
||||
}
|
||||
Asked::OwnerClosed => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Asked::Again => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
Asked::Failed => {
|
||||
started = None;
|
||||
self.failed().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No host answering, or one going away as we came.
|
||||
Err(e) if no_host(&e) => {
|
||||
// A host holding the lock is starting or closing: wait
|
||||
// for it rather than start another.
|
||||
let held = host_lock_held(&self.run);
|
||||
match started {
|
||||
// A start that fails (a spawn error, the helper
|
||||
// gone) is a failure like any other (INVARIANT 3).
|
||||
None if !held => match self.start().await {
|
||||
Ok(()) => started = Some(Instant::now()),
|
||||
Err(_) => self.failed().await?,
|
||||
},
|
||||
None => {}
|
||||
// The one started died (no host holds the lock),
|
||||
// or never answered.
|
||||
Some(at)
|
||||
if (!held && at.elapsed() > LOCK_WAIT) || at.elapsed() > START_WAIT =>
|
||||
{
|
||||
started = None;
|
||||
self.failed().await?;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("can't reach the books' host: {}", e.kind()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a host, through this holder's starter.
|
||||
async fn start(&self) -> Result<(), String> {
|
||||
match &self.starter {
|
||||
Starter::Direct => {
|
||||
let root = self.root.clone();
|
||||
let knobs = self.knobs;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::start::start_host(root.as_deref(), &knobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
Starter::Helper(helper) => {
|
||||
let helper = helper.clone();
|
||||
tokio::task::spawn_blocking(move || helper.start_host())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Say hello on a connection and read the host's first word.
|
||||
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let hello = Hello {
|
||||
proto: lease::PROTO,
|
||||
ask: Ask::Hold {
|
||||
kind: self.kind,
|
||||
pid: std::process::id(),
|
||||
book: self.book.clone(),
|
||||
},
|
||||
};
|
||||
if write
|
||||
.write_all(lease::line(&hello).as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(Asked::Again);
|
||||
}
|
||||
let mut lines = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 * 4)).lines();
|
||||
let first = match tokio::time::timeout(ANSWER_WAIT, lines.next_line()).await {
|
||||
Ok(Ok(Some(line))) => serde_json::from_str::<HostMsg>(&line)
|
||||
.map_err(|e| format!("the books' host answered oddly: {e}"))?,
|
||||
// Gone mid-hello (closing, or a crash): try again.
|
||||
Ok(_) => return Ok(Asked::Again),
|
||||
// Not answering: a failure, tried again after a wait.
|
||||
Err(_) => return Ok(Asked::Failed),
|
||||
};
|
||||
Ok(match first {
|
||||
HostMsg::Held { lease } => Asked::Held(Lease {
|
||||
id: lease,
|
||||
lines,
|
||||
_write: write,
|
||||
}),
|
||||
HostMsg::Ending {
|
||||
reason: Reason::ServerFailed,
|
||||
..
|
||||
} => Asked::Failed,
|
||||
HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
} => Asked::OwnerClosed,
|
||||
HostMsg::Ending { .. } => Asked::Again,
|
||||
HostMsg::Refused(why) => return Err(why),
|
||||
other => return Err(format!("the books' host answered oddly: {other:?}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What a hello came to.
|
||||
enum Asked {
|
||||
Held(Lease),
|
||||
Again,
|
||||
Failed,
|
||||
/// Turned away: the owner is closing the books (INVARIANT 5).
|
||||
OwnerClosed,
|
||||
}
|
||||
|
||||
/// The wait before try `n` (1-based): `base`, doubling, at most 60 s.
|
||||
pub fn backoff(base: Duration, n: u32) -> Duration {
|
||||
let factor = 1u32 << n.saturating_sub(1).min(16);
|
||||
base.saturating_mul(factor).min(MAX_BACKOFF)
|
||||
}
|
||||
|
||||
/// Whether a connect error means no host is answering (none there, or
|
||||
/// one closing as we came), rather than something wrong.
|
||||
fn no_host(e: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
| std::io::ErrorKind::ConnectionRefused
|
||||
| std::io::ErrorKind::ConnectionReset
|
||||
| std::io::ErrorKind::ConnectionAborted
|
||||
| std::io::ErrorKind::BrokenPipe
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a books server holds run/server.lock now (one finishing after
|
||||
/// its host is gone included).
|
||||
pub fn server_lock_held(run: &Path) -> bool {
|
||||
match std::fs::File::open(run.join(ledger_mcp::local::SERVER_LOCK)) {
|
||||
Ok(file) => matches!(
|
||||
file.try_lock_shared(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a host holds run/host.lock now (starting, serving or closing).
|
||||
pub fn host_lock_held(run: &Path) -> bool {
|
||||
match std::fs::File::open(run.join(lease::HOST_LOCK)) {
|
||||
Ok(file) => matches!(
|
||||
file.try_lock_shared(),
|
||||
Err(std::fs::TryLockError::WouldBlock)
|
||||
),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait while a restart holds run/update.lock (INVARIANT 1).
|
||||
async fn wait_for_restart(run: &Path) {
|
||||
// Looked at, not waited on in a thread: a holder that gives up while
|
||||
// a restart runs is never stuck behind it.
|
||||
let path = run.join(lease::UPDATE_LOCK);
|
||||
loop {
|
||||
// No lock file: no restart has ever happened, so none is on.
|
||||
let restarting = std::fs::File::open(&path)
|
||||
.is_ok_and(|f| matches!(f.try_lock_shared(), Err(std::fs::TryLockError::WouldBlock)));
|
||||
if !restarting {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the host one thing on its own connection (leases, an intent):
|
||||
/// `Ok(None)` when no host is running.
|
||||
pub fn ask_once(run: &Path, ask: Ask) -> Result<Option<HostMsg>, String> {
|
||||
ask_once_within(run, ask, Duration::from_secs(120))
|
||||
}
|
||||
|
||||
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
|
||||
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
|
||||
use std::io::{BufRead, Write};
|
||||
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
let stream = match std::os::unix::net::UnixStream::connect(short.path()) {
|
||||
Ok(s) => s,
|
||||
Err(e) if no_host(&e) => return Ok(None),
|
||||
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
|
||||
};
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
return Err("the books' host socket belongs to another account: not used".into());
|
||||
}
|
||||
stream
|
||||
.set_read_timeout(Some(wait))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let not_answering = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
|
||||
"the books' host isn't answering".to_string()
|
||||
}
|
||||
kind => format!("the books' host didn't answer: {kind}"),
|
||||
};
|
||||
(&stream)
|
||||
.write_all(
|
||||
lease::line(&Hello {
|
||||
proto: lease::PROTO,
|
||||
ask,
|
||||
})
|
||||
.as_bytes(),
|
||||
)
|
||||
.map_err(|e| format!("can't reach the books' host: {}", e.kind()))?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&stream, lease::MAX_LINE as u64 * 4))
|
||||
.read_line(&mut answer)
|
||||
.map_err(not_answering)?;
|
||||
if answer.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json::from_str(answer.trim_end())
|
||||
.map(Some)
|
||||
.map_err(|e| format!("the books' host answered oddly: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_wait_doubles_from_the_base_up_to_a_minute() {
|
||||
let s = Duration::from_secs;
|
||||
let waits: Vec<Duration> = (1..=7).map(|n| backoff(s(1), n)).collect();
|
||||
assert_eq!(waits, [s(1), s(2), s(4), s(8), s(16), s(32), s(60)]);
|
||||
assert_eq!(backoff(s(1), 0), s(1));
|
||||
assert_eq!(backoff(s(1), 400), s(60));
|
||||
assert_eq!(
|
||||
backoff(Duration::from_millis(10), 3),
|
||||
Duration::from_millis(40)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn after_five_failures_in_a_row_it_gives_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut o = Opener {
|
||||
root: None,
|
||||
run: dir.path().to_path_buf(),
|
||||
knobs: Knobs {
|
||||
backoff: Duration::from_millis(1),
|
||||
..Knobs::default()
|
||||
},
|
||||
starter: Starter::Direct,
|
||||
kind: Kind::Cli,
|
||||
book: None,
|
||||
failures: 0,
|
||||
opened_at: None,
|
||||
has_held: false,
|
||||
};
|
||||
for _ in 0..MAX_TRIES {
|
||||
o.failed().await.unwrap();
|
||||
}
|
||||
assert_eq!(o.failed().await.unwrap_err(), KEEPS_FAILING);
|
||||
// Opening briefly doesn't clear the count; staying open does.
|
||||
o.opened();
|
||||
assert!(o.failed().await.is_err(), "a brief open cleared the count");
|
||||
o.opened_at = Some(std::time::Instant::now() - STABLE);
|
||||
assert!(o.failed().await.is_ok(), "staying open clears the count");
|
||||
assert_eq!(o.failures, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_going_away_is_no_host() {
|
||||
use std::io::{Error, ErrorKind};
|
||||
for kind in [
|
||||
ErrorKind::NotFound,
|
||||
ErrorKind::ConnectionRefused,
|
||||
ErrorKind::ConnectionReset,
|
||||
ErrorKind::ConnectionAborted,
|
||||
ErrorKind::BrokenPipe,
|
||||
] {
|
||||
assert!(no_host(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::InvalidInput] {
|
||||
assert!(!no_host(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_is_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(ask_once(dir.path(), Ask::Leases).unwrap(), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Starting the host cleanly (plan 3.9, "Starting the host cleanly").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the host never inherits anything from whoever opened the books:
|
||||
//! not their environment, descriptors, session, umask, or the app's
|
||||
//! scope (which would take the host down with the app).
|
||||
//! SOURCE: plan/served.md 3.9; test host_starts_clean, host_survives_app_scope.
|
||||
//! INVARIANTS:
|
||||
//! 1. The host's environment is a fixed list: PATH (fixed), HOME (from
|
||||
//! the account's record, never the opener's), and XDG_RUNTIME_DIR
|
||||
//! and DBUS_SESSION_BUS_ADDRESS only when they point at this
|
||||
//! account's own private runtime folder. Nothing else passes (a
|
||||
//! scope's own identifiers, such as INVOCATION_ID, are systemd's).
|
||||
//! 2. Its standard input and output are null, its errors go to
|
||||
//! run/host.log (0600); it starts a new session with umask 077. Every
|
||||
//! other descriptor is closed (std opens them close-on-exec, and the
|
||||
//! host closes any stray one at start).
|
||||
//! 3. Under a systemd app scope it starts through a transient
|
||||
//! `systemd-run --user --scope` (nothing installed or kept); if the
|
||||
//! scope couldn't be made (systemd-run failed without the host ever
|
||||
//! writing its first line), or there is no such scope, directly. A
|
||||
//! host that ran and stopped is never started a second time.
|
||||
//! 4. It runs this executable's file as it is on disk now.
|
||||
//! 5. The opener doesn't wait for it, and reaps it when it ends.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::Knobs;
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The host's PATH (INVARIANT 1).
|
||||
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
/// Where systemd-run may be (never looked up on the opener's PATH).
|
||||
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
|
||||
|
||||
/// The host's log, in the root's `run/`.
|
||||
pub const HOST_LOG: &str = "host.log";
|
||||
/// A log larger than this is moved aside when a host starts.
|
||||
const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// This executable's file as it is on disk now (INVARIANT 4): on Linux a
|
||||
/// replaced file reads as "… (deleted)"; the path, not the old file, is
|
||||
/// what a new host runs.
|
||||
pub fn this_executable() -> Result<PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
|
||||
let text = exe.to_string_lossy();
|
||||
if let Some(path) = text.strip_suffix(" (deleted)")
|
||||
&& !exe.exists()
|
||||
{
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(exe)
|
||||
}
|
||||
|
||||
/// This account's user ID.
|
||||
fn own_uid() -> u32 {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
|
||||
/// runtime folder: absolute, a real folder (no link), owned by this
|
||||
/// account, and closed to everyone else.
|
||||
pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
|
||||
// Written plainly (no trailing slash, no `.`), as a bus address
|
||||
// names it.
|
||||
let dir: PathBuf = PathBuf::from(value?).components().collect();
|
||||
if !dir.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(&dir).ok()?;
|
||||
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
|
||||
}
|
||||
|
||||
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
|
||||
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
|
||||
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
|
||||
let value = value?.to_str()?;
|
||||
let want = format!("unix:path={}/bus", runtime.display());
|
||||
let rest = value.strip_prefix(&want)?;
|
||||
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// This account's runtime folder: the one named in the environment if it
|
||||
/// is ours and private; else, when the opener's environment has none (a
|
||||
/// client that scrubs it, as Codex does), the system's for this account,
|
||||
/// `/run/user/<uid>`, under the same checks.
|
||||
fn runtime_dir() -> Option<PathBuf> {
|
||||
// The one named, if it passes; else (none named, or one that isn't
|
||||
// ours or private) the system's for this account.
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.and_then(|named| own_runtime_dir(Some(&named)))
|
||||
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
|
||||
}
|
||||
|
||||
/// The session bus in `runtime`: the one named in the environment if it
|
||||
/// is there, else its usual socket there, if one exists.
|
||||
fn session_bus(runtime: &Path) -> Option<String> {
|
||||
match std::env::var_os("DBUS_SESSION_BUS_ADDRESS") {
|
||||
Some(named) => own_bus(Some(&named), runtime),
|
||||
None => {
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
let socket = runtime.join("bus");
|
||||
std::fs::symlink_metadata(&socket)
|
||||
.is_ok_and(|m| m.file_type().is_socket() && m.uid() == own_uid())
|
||||
.then(|| format!("unix:path={}", socket.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's environment (INVARIANT 1).
|
||||
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
|
||||
let mut env = vec![
|
||||
("PATH".to_string(), PATH.into()),
|
||||
("HOME".to_string(), home.as_os_str().to_os_string()),
|
||||
];
|
||||
if let Some(runtime) = runtime_dir() {
|
||||
if let Some(bus) = session_bus(&runtime) {
|
||||
env.push(("DBUS_SESSION_BUS_ADDRESS".into(), bus.into()));
|
||||
}
|
||||
env.push(("XDG_RUNTIME_DIR".into(), runtime.into_os_string()));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Whether this process sits in a unit under this account's user manager
|
||||
/// (an app's scope or service, as uwsm makes for every app), whose stop
|
||||
/// would take the host with it; going by the unified cgroup line of
|
||||
/// `/proc/self/cgroup`. The manager's own `init.scope` is not such a
|
||||
/// unit.
|
||||
pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
|
||||
let manager = format!("/user@{uid}.service/");
|
||||
cgroup.lines().any(|line| {
|
||||
line.strip_prefix("0::").is_some_and(|path| {
|
||||
path.split_once(manager.as_str())
|
||||
.is_some_and(|(_, unit)| !unit.is_empty() && unit != "init.scope")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// systemd-run, if this process is in an app scope and the user manager
|
||||
/// can be reached (INVARIANT 3).
|
||||
fn systemd_run() -> Option<&'static str> {
|
||||
let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
|
||||
if !in_app_scope(&cgroup, own_uid()) {
|
||||
return None;
|
||||
}
|
||||
// systemd-run reaches the user manager through the runtime folder,
|
||||
// which host_env gives it even when the opener's environment didn't.
|
||||
runtime_dir()?;
|
||||
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
/// Start a host for the root (`root`, else the one the host finds
|
||||
/// itself), with these knobs (INVARIANTS 1-5).
|
||||
pub fn start_host(root: Option<&Path>, knobs: &Knobs) -> Result<(), String> {
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
|
||||
let exe = this_executable()?;
|
||||
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
|
||||
// An explicit root goes on as found (absolute): the host starts in /.
|
||||
if root.is_some() {
|
||||
args.push("--root".into());
|
||||
args.push(found.path().as_os_str().to_os_string());
|
||||
}
|
||||
args.extend(knobs.args().into_iter().map(Into::into));
|
||||
let log = || {
|
||||
let path = run.join(HOST_LOG);
|
||||
// A large log is moved aside first (to `.1`), never from under a
|
||||
// host writing it.
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
|
||||
&& !crate::opener::host_lock_held(&run)
|
||||
{
|
||||
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
|
||||
};
|
||||
// Through systemd-run (`Some`), or directly.
|
||||
let command = |through: Option<&str>, log: std::fs::File| -> Result<Command, String> {
|
||||
let mut c = match through {
|
||||
Some(systemd_run) => {
|
||||
let mut c = Command::new(systemd_run);
|
||||
c.args(["--user", "--scope", "--quiet", "--collect", "--"])
|
||||
.arg(&exe);
|
||||
c
|
||||
}
|
||||
None => Command::new(&exe),
|
||||
};
|
||||
c.args(&args)
|
||||
.env_clear()
|
||||
.envs(host_env(&home))
|
||||
.current_dir("/")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(log);
|
||||
// SAFETY: setsid and umask are async-signal-safe and touch only
|
||||
// the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
libc::umask(0o077);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Ok(c)
|
||||
};
|
||||
if let Some(systemd_run) = systemd_run() {
|
||||
// The log as this start opened it, and where it stands now: what
|
||||
// the host writes after this is read back from that same file,
|
||||
// even if another opener moves the log aside meanwhile.
|
||||
let file = log()?;
|
||||
let from = file.metadata().map_or(0, |m| m.len());
|
||||
let watch = file
|
||||
.try_clone()
|
||||
.map_err(|e| format!("can't open the host's log: {}", e.kind()))?;
|
||||
if let Ok(mut child) = command(Some(systemd_run), file)?.spawn()
|
||||
&& scope_made(&mut child, &watch, from)
|
||||
{
|
||||
reap(child);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let child = command(None, log()?)?
|
||||
.spawn()
|
||||
.map_err(|e| format!("can't start the books' host: {e}"))?;
|
||||
reap(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long systemd-run has to make its scope and run the host.
|
||||
const SCOPE_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
|
||||
/// host's first line is in its log after `from`, or systemd-run is still
|
||||
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
|
||||
/// failing with no host line means it didn't: then the host is started
|
||||
/// directly. (systemd-run's own complaints go to the same log, so the
|
||||
/// log growing proves nothing; the host's line does.)
|
||||
fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -> bool {
|
||||
let host_ran = || {
|
||||
use std::os::unix::fs::FileExt;
|
||||
let len = log.metadata().map_or(0, |m| m.len());
|
||||
let mut tail = vec![0u8; len.saturating_sub(from).min(64 * 1024) as usize];
|
||||
log.read_exact_at(&mut tail, from).is_ok()
|
||||
&& String::from_utf8_lossy(&tail).contains(crate::host::STARTED)
|
||||
};
|
||||
let deadline = Instant::now() + SCOPE_WAIT;
|
||||
loop {
|
||||
if host_ran() {
|
||||
return true;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success() || host_ran(),
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
|
||||
fn reap(mut child: std::process::Child) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("reap-host".into())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn an_app_scope_is_known_by_its_unified_cgroup() {
|
||||
let uwsm = "0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-Hyprland-kitty-1234.scope\n";
|
||||
assert!(in_app_scope(uwsm, 1000));
|
||||
assert!(!in_app_scope(uwsm, 1001), "another account's manager");
|
||||
// An app run as a service unit (uwsm's app-…@….service) too.
|
||||
assert!(in_app_scope(
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foo@x.service\n",
|
||||
1000
|
||||
));
|
||||
for not in [
|
||||
"0::/user.slice/user-1000.slice/session-2.scope\n",
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/init.scope\n",
|
||||
"0::/user.slice/user-1000.slice/user@1000.service/\n",
|
||||
"0::/\n",
|
||||
"1:name=systemd:/user.slice/user-1000.slice/user@1000.service/app.slice/x.scope\n",
|
||||
"",
|
||||
] {
|
||||
assert!(!in_app_scope(not, 1000), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_own_private_runtime_folder_passes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rt = dir.path().join("rt");
|
||||
std::fs::create_dir(&rt).unwrap();
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert_eq!(own_runtime_dir(Some(rt.as_os_str())), Some(rt.clone()));
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o750)).unwrap();
|
||||
assert_eq!(
|
||||
own_runtime_dir(Some(rt.as_os_str())),
|
||||
None,
|
||||
"group can enter"
|
||||
);
|
||||
std::fs::set_permissions(&rt, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&rt, &link).unwrap();
|
||||
assert_eq!(own_runtime_dir(Some(link.as_os_str())), None, "a link");
|
||||
assert_eq!(own_runtime_dir(Some("relative/rt".as_ref())), None);
|
||||
assert_eq!(
|
||||
own_runtime_dir(Some(dir.path().join("none").as_os_str())),
|
||||
None
|
||||
);
|
||||
assert_eq!(own_runtime_dir(None), None);
|
||||
// Owned by root, not us.
|
||||
if own_uid() != 0 {
|
||||
assert_eq!(own_runtime_dir(Some("/".as_ref())), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_bus_in_our_runtime_folder_passes() {
|
||||
let rt = Path::new("/run/user/1000");
|
||||
assert_eq!(
|
||||
own_bus(Some("unix:path=/run/user/1000/bus".as_ref()), rt).as_deref(),
|
||||
Some("unix:path=/run/user/1000/bus")
|
||||
);
|
||||
assert!(own_bus(Some("unix:path=/run/user/1000/bus,guid=ab".as_ref()), rt).is_some());
|
||||
for bad in [
|
||||
"unix:path=/tmp/evil/bus",
|
||||
"unix:path=/run/user/1000/bus2",
|
||||
"unix:path=/run/user/1000/busx,guid=1",
|
||||
"tcp:host=evil.example,port=1",
|
||||
"unix:abstract=/run/user/1000/bus",
|
||||
] {
|
||||
assert_eq!(own_bus(Some(bad.as_ref()), rt), None, "{bad}");
|
||||
}
|
||||
assert_eq!(own_bus(None, rt), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_environment_carries_nothing_else() {
|
||||
let env = host_env(Path::new("/home/someone"));
|
||||
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(&names[..2], ["PATH", "HOME"]);
|
||||
for n in &names[2..] {
|
||||
assert!(
|
||||
["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].contains(n),
|
||||
"{n}"
|
||||
);
|
||||
}
|
||||
assert_eq!(env[0].1, PATH);
|
||||
assert_eq!(env[1].1, "/home/someone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_executable_is_a_file_on_disk() {
|
||||
let exe = this_executable().unwrap();
|
||||
assert!(exe.is_file(), "{exe:?}");
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,15 @@ mime_guess = "2"
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
|
||||
axum = "0.8"
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the
|
||||
//! command line asks the books server to list, create and adopt books.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the command line makes and adopts books without ever touching
|
||||
//! the data root itself: the server, its sole writer, does it.
|
||||
//! SOURCE: plan/served.md 3.1 (the CLI "talks to run/admin.sock ... it can
|
||||
//! create or adopt books"), 5.5 (adoption), 3.9 (protocol versions
|
||||
//! N and N-1).
|
||||
//! INVARIANTS:
|
||||
//! 1. One request per connection, one JSON line each way, at most
|
||||
//! MAX_LINE bytes; anything else is refused with a plain reason.
|
||||
//! 2. A request carries its protocol version; the server accepts this
|
||||
//! version and the one before, and refuses others with a message
|
||||
//! that says to restart.
|
||||
//! 3. No answer names the data root or a path in it, nor echoes a path
|
||||
//! the caller gave (which could be the root under another name).
|
||||
//! 4. Adopting is two steps: without `confirmed` the answer is a
|
||||
//! preview, and nothing is written; `confirmed: true` adopts, and
|
||||
//! only books still at the head the preview read (`expect_head`).
|
||||
//! 5. Nothing in a settings file is dropped silently: entries the books
|
||||
//! don't know are refused unless `skip_unknown`, and its notes and
|
||||
//! comments are in the preview.
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// This protocol's version (INVARIANT 2).
|
||||
pub const PROTO: u32 = 1;
|
||||
|
||||
/// The oldest version this side still accepts (INVARIANT 2: this one and
|
||||
/// the one before; there is none before the first).
|
||||
pub const OLDEST: u32 = 1;
|
||||
|
||||
/// Whether a request of protocol version `proto` is accepted.
|
||||
pub fn accepts(proto: u32) -> bool {
|
||||
(OLDEST..=PROTO).contains(&proto)
|
||||
}
|
||||
|
||||
/// The longest line either side sends, its newline not counted
|
||||
/// (INVARIANT 1): settings texts ride in an adopt request.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// A request, with its protocol version.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What is asked.
|
||||
#[serde(flatten)]
|
||||
pub request: Request,
|
||||
}
|
||||
|
||||
/// What the command line can ask.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Request {
|
||||
/// The books the server holds.
|
||||
List,
|
||||
/// Start new, empty books.
|
||||
Create {
|
||||
/// The new books' id.
|
||||
id: String,
|
||||
/// Their base currency (ISO 4217), fixed for good.
|
||||
currency: String,
|
||||
},
|
||||
/// Adopt existing books (plan 5.5).
|
||||
Adopt {
|
||||
/// The new books' id.
|
||||
id: String,
|
||||
/// The data dir to adopt (`journal.jsonl`, `blobs/`).
|
||||
source: PathBuf,
|
||||
/// The settings file's text, for books that keep no settings.
|
||||
#[serde(default)]
|
||||
settings_toml: Option<String>,
|
||||
/// `config.json`'s text, for its receipt policy.
|
||||
#[serde(default)]
|
||||
config_json: Option<String>,
|
||||
/// The source's TIN vault folder.
|
||||
#[serde(default)]
|
||||
vault_dir: Option<PathBuf>,
|
||||
/// The v1 vault key file.
|
||||
#[serde(default)]
|
||||
vault_key: Option<PathBuf>,
|
||||
/// Adopt (true), or only preview what would be moved in.
|
||||
#[serde(default)]
|
||||
confirmed: bool,
|
||||
/// The head the preview read: adopted only if still so.
|
||||
#[serde(default)]
|
||||
expect_head: Option<(u64, String)>,
|
||||
/// Leave out settings-file entries the books don't know.
|
||||
#[serde(default)]
|
||||
skip_unknown: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One book, as listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BookStatus {
|
||||
/// Its id.
|
||||
pub id: String,
|
||||
/// Whether it is open and served.
|
||||
pub open: bool,
|
||||
/// Why it couldn't be opened, if it couldn't.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// A setting an adoption would move in.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SettingChange {
|
||||
/// Its key.
|
||||
pub key: String,
|
||||
/// Its value, as JSON (`None`: the setting is cleared).
|
||||
pub value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// What an unconfirmed adoption would do (INVARIANT 4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AdoptPreview {
|
||||
/// The source's last event, `(seq, hash)`, as read now.
|
||||
pub source_head: (u64, String),
|
||||
/// The source already keeps its own settings: none would be moved in.
|
||||
pub keeps_settings: bool,
|
||||
/// The settings that would be moved in.
|
||||
pub settings: Vec<SettingChange>,
|
||||
/// What the settings file says that isn't a books setting.
|
||||
pub notes: Vec<String>,
|
||||
/// Entries in it the books don't know (not moved in).
|
||||
pub unknown: Vec<String>,
|
||||
/// The settings file's comments, in order (they aren't settings: keep
|
||||
/// what matters as a convention or a decision).
|
||||
pub comments: Vec<String>,
|
||||
/// The vault's records, each checked to open with the key given.
|
||||
pub vault_records: usize,
|
||||
}
|
||||
|
||||
/// What an adoption did.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adopted {
|
||||
/// The new books' id.
|
||||
pub id: String,
|
||||
/// The source's head, verified before copying.
|
||||
pub source_head: (u64, String),
|
||||
/// The adopted books' head.
|
||||
pub head: (u64, String),
|
||||
/// Stored files copied.
|
||||
pub blob_files: u64,
|
||||
/// Stored files the books name, re-hashed through the store.
|
||||
pub blobs_verified: u64,
|
||||
/// Stored files copied that the books don't name.
|
||||
pub unnamed_blobs: u64,
|
||||
/// Files left behind among the stored ones (unfinished writes, hidden
|
||||
/// files).
|
||||
pub skipped_files: u64,
|
||||
/// Vault records copied.
|
||||
pub vault_files: u64,
|
||||
/// Whether the v1 key was copied.
|
||||
pub key_copied: bool,
|
||||
/// Settings moved in.
|
||||
pub settings_imported: usize,
|
||||
/// Settings were given, but the books keep their own.
|
||||
pub settings_ignored: bool,
|
||||
/// The source had no lock file, so one was made beside its journal.
|
||||
pub source_lock_created: bool,
|
||||
/// Why the adopted books didn't open, if they didn't (they are kept;
|
||||
/// closing the books and opening them again tries again).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// The server's answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Response {
|
||||
/// The books held.
|
||||
Books(Vec<BookStatus>),
|
||||
/// New books made.
|
||||
Created {
|
||||
/// Their id.
|
||||
id: String,
|
||||
/// Why they didn't open, if they didn't (they are kept).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
problem: Option<String>,
|
||||
},
|
||||
/// What an adoption would do.
|
||||
Preview(AdoptPreview),
|
||||
/// Books adopted and open.
|
||||
Adopted(Adopted),
|
||||
/// Refused, with why (no path in the data root: INVARIANT 3).
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// Why an admin call has no answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CallError {
|
||||
/// The request never reached the server: nothing was done.
|
||||
NotSent(String),
|
||||
/// It was sent, and no answer came: it may or may not have been done.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CallError::NotSent(why) | CallError::Unknown(why) => f.write_str(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as
|
||||
/// long as it takes (a create or an adoption).
|
||||
#[cfg(unix)]
|
||||
pub fn call(socket: &std::path::Path, request: Request) -> Result<Response, CallError> {
|
||||
call_within(socket, request, None)
|
||||
}
|
||||
|
||||
/// [`call`], waiting at most `wait` for the answer (a quick look, such
|
||||
/// as `doctor`'s).
|
||||
#[cfg(unix)]
|
||||
pub fn call_within(
|
||||
socket: &std::path::Path,
|
||||
request: Request,
|
||||
wait: Option<std::time::Duration>,
|
||||
) -> Result<Response, CallError> {
|
||||
use std::io::{BufRead, Write};
|
||||
let not_sent = |why: String| CallError::NotSent(why);
|
||||
// A list changes nothing: its only question is whether it answered.
|
||||
let reads_only = matches!(request, Request::List);
|
||||
let unknown = move |why: String| {
|
||||
if reads_only {
|
||||
CallError::Unknown(why)
|
||||
} else {
|
||||
CallError::Unknown(format!(
|
||||
"{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are"
|
||||
))
|
||||
}
|
||||
};
|
||||
let quiet = |e: std::io::Error| match e.kind() {
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
|
||||
"the books server isn't answering".to_string()
|
||||
}
|
||||
kind => format!("the books server didn't answer ({kind})"),
|
||||
};
|
||||
let (dir, name) = (
|
||||
socket.parent().unwrap_or(std::path::Path::new(".")),
|
||||
socket
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let short = ledger_store::root::SocketPath::new(dir, name)
|
||||
.map_err(|e| not_sent(format!("can't reach the books server: {e}")))?;
|
||||
let stream =
|
||||
std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
|
||||
not_sent("the books server isn't running".to_string())
|
||||
}
|
||||
_ => not_sent(format!("can't reach the books server: {}", e.kind())),
|
||||
})?;
|
||||
crate::local::check_peer_std(&stream).map_err(not_sent)?;
|
||||
stream
|
||||
.set_read_timeout(wait)
|
||||
.map_err(|e| not_sent(e.to_string()))?;
|
||||
let mut line = serde_json::to_string(&Envelope {
|
||||
proto: PROTO,
|
||||
request,
|
||||
})
|
||||
.map_err(|e| not_sent(e.to_string()))?;
|
||||
line.push('\n');
|
||||
// A write cut part way may still have reached the server whole.
|
||||
(&stream).write_all(line.as_bytes()).map_err(|e| {
|
||||
unknown(format!(
|
||||
"the books server stopped taking the request ({})",
|
||||
e.kind()
|
||||
))
|
||||
})?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&stream, MAX_LINE as u64 + 1))
|
||||
.read_line(&mut answer)
|
||||
.map_err(|e| unknown(quiet(e)))?;
|
||||
if answer.is_empty() {
|
||||
return Err(unknown("the books server stopped before answering".into()));
|
||||
}
|
||||
serde_json::from_str(answer.trim_end())
|
||||
.map_err(|e| unknown(format!("the books server's answer isn't understood ({e})")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn requests_are_one_flat_object_with_their_version() {
|
||||
let line = serde_json::to_value(Envelope {
|
||||
proto: PROTO,
|
||||
request: Request::Create {
|
||||
id: "acme".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
line,
|
||||
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD"})
|
||||
);
|
||||
let back: Envelope = serde_json::from_value(line).unwrap();
|
||||
assert_eq!(back.proto, 1);
|
||||
let list: Envelope = serde_json::from_value(json!({"proto": 1, "op": "list"})).unwrap();
|
||||
assert_eq!(list.request, Request::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopt_request_is_a_preview_unless_confirmed() {
|
||||
let r: Envelope = serde_json::from_value(
|
||||
json!({"proto": 1, "op": "adopt", "id": "acme", "source": "/x/books-data"}),
|
||||
)
|
||||
.unwrap();
|
||||
match r.request {
|
||||
Request::Adopt {
|
||||
confirmed,
|
||||
settings_toml,
|
||||
vault_dir,
|
||||
expect_head,
|
||||
skip_unknown,
|
||||
..
|
||||
} => {
|
||||
assert!(!confirmed && !skip_unknown);
|
||||
assert!(settings_toml.is_none() && vault_dir.is_none() && expect_head.is_none());
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_operations_and_fields_are_refused() {
|
||||
for bad in [
|
||||
json!({"proto": 1, "op": "delete", "id": "acme"}),
|
||||
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD", "path": "/x"}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Envelope>(bad.clone()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_round_trip() {
|
||||
for r in [
|
||||
Response::Books(vec![BookStatus {
|
||||
id: "acme".into(),
|
||||
open: false,
|
||||
problem: Some("damaged".into()),
|
||||
}]),
|
||||
Response::Created {
|
||||
id: "acme".into(),
|
||||
problem: None,
|
||||
},
|
||||
Response::Refused("no".into()),
|
||||
] {
|
||||
let text = serde_json::to_string(&r).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,12 @@
|
||||
//! `/`, the home folder, or holds the books, the vault or a key is refused
|
||||
//! at startup.
|
||||
|
||||
pub mod admin;
|
||||
mod handbook;
|
||||
#[cfg(unix)]
|
||||
pub mod local;
|
||||
mod local_files;
|
||||
pub mod opening;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod settings;
|
||||
@@ -34,6 +38,7 @@ use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Server configuration, JSON. All fields optional.
|
||||
@@ -138,6 +143,14 @@ pub fn run(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Books in the data root are the books server's alone (plan 3.1):
|
||||
// a second writer beside it is refused before anything is opened.
|
||||
if ledger_store::root::inside_a_root(&data_dir) {
|
||||
eprintln!(
|
||||
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
|
||||
);
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
let import_roots = if import_roots.is_empty() {
|
||||
ImportRoots::defaults()
|
||||
} else {
|
||||
@@ -221,7 +234,12 @@ pub fn run(
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
|
||||
let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) {
|
||||
let import_roots = match server::guard_import_roots(
|
||||
&store,
|
||||
&settings_now,
|
||||
&server::Layout::BooksDir,
|
||||
import_roots,
|
||||
) {
|
||||
Ok(roots) => roots,
|
||||
Err(why) => {
|
||||
eprintln!("ledger-mcp: {why}");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,89 @@
|
||||
//! What the agents' bridge answers `initialize` with while the books are
|
||||
//! still opening (plan 3.9, "The bridge and slow starts").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent whose session starts while the books are closed gets
|
||||
//! its `initialize` answered within its client's startup timeout,
|
||||
//! with capabilities that are the server's own.
|
||||
//! SOURCE: plan/served.md 3.9 (the bridge answers initialize itself after
|
||||
//! 5 s, "from the tool list built into the same executable and a
|
||||
//! short generic text that points at books.start").
|
||||
//! INVARIANTS:
|
||||
//! 1. The capabilities are the server's (one function builds both).
|
||||
//! 2. The protocol version is the one asked for when this build knows
|
||||
//! it and it has an initialize handshake, else the newest that does.
|
||||
//! 3. The text names no book and no folder: it points at books.start.
|
||||
//! ```
|
||||
|
||||
use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerConfig};
|
||||
|
||||
/// The text an agent gets while the books open (INVARIANT 3).
|
||||
pub const OPENING_TEXT: &str = "wanderbooks: double-entry books, opening now. Call books.start first: it returns these books' rules, conventions and open items, and every procedure is in books.handbook. Calls wait while the books open.";
|
||||
|
||||
/// The server's capabilities (INVARIANT 1).
|
||||
pub(crate) fn capabilities() -> ServerCapabilities {
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build()
|
||||
}
|
||||
|
||||
/// The `initialize` result the bridge gives while the books open, for a
|
||||
/// client that asked for protocol version `requested`.
|
||||
pub fn opening_initialize(requested: Option<&str>) -> serde_json::Value {
|
||||
let version = requested
|
||||
.and_then(|asked| {
|
||||
ProtocolVersion::KNOWN_VERSIONS
|
||||
.iter()
|
||||
.find(|v| v.as_str() == asked && v.has_initialize())
|
||||
})
|
||||
.cloned()
|
||||
.unwrap_or(ProtocolVersion::LATEST_WITH_INITIALIZE);
|
||||
let mut info = ServerConfig::new(capabilities())
|
||||
.with_instructions(OPENING_TEXT)
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
));
|
||||
info.protocol_version = version;
|
||||
serde_json::to_value(info).expect("an initialize result is plain data")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_version_asked_for_is_kept_when_known() {
|
||||
for v in ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] {
|
||||
assert_eq!(opening_initialize(Some(v))["protocolVersion"], v);
|
||||
}
|
||||
let newest = ProtocolVersion::LATEST_WITH_INITIALIZE.as_str().to_string();
|
||||
for v in [
|
||||
Some("1999-01-01"),
|
||||
Some("2026-07-28"),
|
||||
Some("nonsense"),
|
||||
None,
|
||||
] {
|
||||
assert_eq!(
|
||||
opening_initialize(v)["protocolVersion"],
|
||||
newest.as_str(),
|
||||
"{v:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_names_the_server_and_points_at_books_start() {
|
||||
let r = opening_initialize(None);
|
||||
assert_eq!(r["serverInfo"]["name"], "wanderbooks");
|
||||
assert_eq!(r["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert!(r["instructions"].as_str().unwrap().contains("books.start"));
|
||||
assert_eq!(
|
||||
r["capabilities"],
|
||||
serde_json::to_value(capabilities()).unwrap()
|
||||
);
|
||||
assert!(r["capabilities"]["tools"].is_object());
|
||||
assert!(r["capabilities"]["resources"].is_object());
|
||||
}
|
||||
}
|
||||
+226
-48
@@ -50,7 +50,7 @@ use ledger_store::Store;
|
||||
use rmcp::handler::server::wrapper::{Json, Parameters};
|
||||
use rmcp::model::{
|
||||
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
|
||||
ReadResourceResult, Resource, ResourceContents, ServerConfig,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
|
||||
@@ -70,6 +70,53 @@ pub struct BooksServer {
|
||||
store: Arc<SharedStore>,
|
||||
settings: Arc<Mutex<Live>>,
|
||||
import_roots: Arc<ImportRoots>,
|
||||
layout: Arc<Layout>,
|
||||
}
|
||||
|
||||
/// Where a book's own files live around its data dir.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// A books dir, as before P2: the data dir is `books-data/` in a
|
||||
/// project folder (the books dir), which holds `vault/`, the settings
|
||||
/// file and perhaps git; the vault's key lives outside it.
|
||||
BooksDir,
|
||||
/// A book in the data root (plan 3.4): the data dir is `books/<id>/`,
|
||||
/// holding its own `vault/` and its v1 key in `keys/legacy.key`. The
|
||||
/// root (the path given) plays the books dir's part: nothing in it is
|
||||
/// ever read or written through a path a tool names.
|
||||
Root(std::path::PathBuf),
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// What a tool never reads or writes through a path: the books dir,
|
||||
/// or the whole data root.
|
||||
fn books_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store),
|
||||
Layout::Root(root) => root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The TIN vault's folder.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => books_dir(store).join("vault"),
|
||||
Layout::Root(_) => store.data_dir().join("vault"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vault's key file: in the data root always the book's own
|
||||
/// `keys/legacy.key`, whatever a setting says; in a books dir the one
|
||||
/// in force (`[vault] key_file`, else the default outside the books).
|
||||
fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf {
|
||||
match self {
|
||||
Layout::BooksDir => settings.key_file(),
|
||||
Layout::Root(_) => store
|
||||
.data_dir()
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The one store every session shares, and whether it may still be used.
|
||||
@@ -86,10 +133,20 @@ pub struct BooksServer {
|
||||
/// store), `stopped` is set and never cleared in this process.
|
||||
/// 2. While `stopped`, every request is refused with STOPPED, before
|
||||
/// it touches the store; a restart re-verifies the journal.
|
||||
/// 3. Once `close` begins, no request starts running (each is refused
|
||||
/// with NOT_RUN_CLOSING, untouched), and `close` returns only when
|
||||
/// every request that did start has finished its blocking work,
|
||||
/// whether or not anyone still waits for its answer.
|
||||
/// ```
|
||||
pub struct SharedStore {
|
||||
store: tokio::sync::Mutex<Store>,
|
||||
stopped: std::sync::atomic::AtomicBool,
|
||||
/// Closing: no new request runs (INVARIANT 3).
|
||||
closing: std::sync::atomic::AtomicBool,
|
||||
/// Requests running (from before their blocking task to its end).
|
||||
running: std::sync::atomic::AtomicUsize,
|
||||
/// Woken whenever one ends.
|
||||
ended: tokio::sync::Notify,
|
||||
/// The books' base currency, fixed at their creation: read without
|
||||
/// the lock (the connect text names it).
|
||||
currency: Currency,
|
||||
@@ -102,9 +159,31 @@ impl SharedStore {
|
||||
currency: store.base_currency(),
|
||||
store: tokio::sync::Mutex::new(store),
|
||||
stopped: std::sync::atomic::AtomicBool::new(false),
|
||||
closing: std::sync::atomic::AtomicBool::new(false),
|
||||
running: std::sync::atomic::AtomicUsize::new(0),
|
||||
ended: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop new requests from running (INVARIANT 3), and wait until every
|
||||
/// one already running has ended: its blocking task finished, even if
|
||||
/// its client hung up.
|
||||
pub async fn close(&self) {
|
||||
self.closing
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
loop {
|
||||
let ended = self.ended.notified();
|
||||
if self.running.load(std::sync::atomic::Ordering::SeqCst) == 0 {
|
||||
return;
|
||||
}
|
||||
ended.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn closing(&self) -> bool {
|
||||
self.closing.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn stopped(&self) -> bool {
|
||||
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
@@ -179,7 +258,15 @@ where
|
||||
if shared.stopped() {
|
||||
return Err(rmcp::ErrorData::internal_error(STOPPED, None));
|
||||
}
|
||||
// SharedStore INVARIANT 3: counted before the closing check, so a
|
||||
// close never misses it; the count rides in the blocking task, which
|
||||
// runs to its end even when this future is dropped.
|
||||
let running = Running::new(shared);
|
||||
if shared.closing() {
|
||||
return Err(not_run_closing());
|
||||
}
|
||||
let joined = tokio::task::spawn_blocking(move || {
|
||||
let _running = running;
|
||||
struct Unpark(std::thread::Thread);
|
||||
impl std::task::Wake for Unpark {
|
||||
fn wake(self: Arc<Self>) {
|
||||
@@ -205,6 +292,43 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// A request running, counted in its store (SharedStore INVARIANT 3).
|
||||
struct Running(Arc<SharedStore>);
|
||||
|
||||
impl Running {
|
||||
fn new(shared: &Arc<SharedStore>) -> Self {
|
||||
shared
|
||||
.running
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Self(shared.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.0
|
||||
.running
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
self.0.ended.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON-RPC error code of a request refused because the books are
|
||||
/// closing: it didn't run, so a client may send it again once they are
|
||||
/// open (the agents' bridge does).
|
||||
pub const NOT_RUN_CLOSING_CODE: i32 = -32050;
|
||||
|
||||
/// What a request refused while closing is told.
|
||||
pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run";
|
||||
|
||||
fn not_run_closing() -> rmcp::ErrorData {
|
||||
rmcp::ErrorData::new(
|
||||
rmcp::model::ErrorCode(NOT_RUN_CLOSING_CODE),
|
||||
NOT_RUN_CLOSING,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub use crate::settings::CashRoles;
|
||||
|
||||
@@ -403,9 +527,17 @@ impl BooksServer {
|
||||
..Settings::default()
|
||||
}))),
|
||||
import_roots: Arc::new(ImportRoots::defaults()),
|
||||
layout: Arc::new(Layout::BooksDir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a book kept in the data root (`Layout::Root`) rather than
|
||||
/// in a books dir.
|
||||
pub fn with_layout(mut self, layout: Layout) -> Self {
|
||||
self.layout = Arc::new(layout);
|
||||
self
|
||||
}
|
||||
|
||||
/// The folders `books.import_local_file` may read from (local_files.rs).
|
||||
pub fn with_import_roots(mut self, roots: ImportRoots) -> Self {
|
||||
self.import_roots = Arc::new(roots);
|
||||
@@ -727,9 +859,10 @@ impl BooksServer {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored).
|
||||
fn vault_dir(store: &Store) -> std::path::PathBuf {
|
||||
books_dir(store).join("vault")
|
||||
/// The TIN vault's directory: `vault/` in the books dir (git-ignored),
|
||||
/// or in the book's own folder in the data root.
|
||||
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
|
||||
self.layout.vault_dir(store)
|
||||
}
|
||||
|
||||
/// What is never read through a path, even inside an import root
|
||||
@@ -739,7 +872,7 @@ impl BooksServer {
|
||||
/// the rest of the books dir.
|
||||
fn protected(&self, store: &Store) -> Protected {
|
||||
Protected::of(
|
||||
&places(store, &self.settings()),
|
||||
&places(store, &self.settings(), &self.layout),
|
||||
&self.import_roots.usable(),
|
||||
)
|
||||
}
|
||||
@@ -757,7 +890,7 @@ impl BooksServer {
|
||||
file_name: &str,
|
||||
) -> Result<(), String> {
|
||||
tin_files::refuse(
|
||||
&Self::vault_dir(store),
|
||||
&self.vault_dir(store),
|
||||
bytes,
|
||||
mime_type,
|
||||
file_name,
|
||||
@@ -809,7 +942,7 @@ impl BooksServer {
|
||||
/// the books (outside_books) and outside every import root, so it can
|
||||
/// never be filed back in as a receipt and read out again.
|
||||
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dest = outside_books(store, path)?;
|
||||
let dest = outside_books(&self.layout.books_dir(store), path)?;
|
||||
self.outside_import_roots(&dest)?;
|
||||
Ok(dest)
|
||||
}
|
||||
@@ -854,8 +987,17 @@ impl BooksServer {
|
||||
/// Record a file read or written with full TINs (tin_files.rs
|
||||
/// INVARIANT 2), before it is written out: every session, and every
|
||||
/// later server for these books, refuses it.
|
||||
fn wrote_tins(store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
tin_files::record(&Self::vault_dir(store), bytes)
|
||||
fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> {
|
||||
// The record goes in the vault's folder: in the data root, made
|
||||
// private first, as the vault itself makes it.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
ledger_store::root::private_book_folder(
|
||||
store.data_dir(),
|
||||
ledger_store::root::VAULT_DIR,
|
||||
)
|
||||
.map_err(|e| format!("the TIN vault can't be written: {e}"))?;
|
||||
}
|
||||
tin_files::record(&self.vault_dir(store), bytes)
|
||||
}
|
||||
|
||||
/// Open the TIN vault with the key file in force (`[vault] key_file`,
|
||||
@@ -865,13 +1007,23 @@ impl BooksServer {
|
||||
/// records it unlocks. Storing into books kept in git is refused
|
||||
/// unless their `.gitignore` lists `vault/`.
|
||||
fn open_vault(&self, store: &Store, create: bool) -> Result<ledger_vault::Vault, String> {
|
||||
let key = self.settings().key_file();
|
||||
if is_inside(&books_dir(store), &key) {
|
||||
let key = self.layout.key_file(store, &self.settings());
|
||||
// In the data root the key is the book's own `keys/legacy.key`;
|
||||
// its folder and the vault's are made private when a TIN is first
|
||||
// stored.
|
||||
if let Layout::Root(_) = *self.layout {
|
||||
if create {
|
||||
for folder in [ledger_store::root::KEYS_DIR, "vault"] {
|
||||
ledger_store::root::private_book_folder(store.data_dir(), folder)
|
||||
.map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?;
|
||||
}
|
||||
}
|
||||
} else if is_inside(&books_dir(store), &key) {
|
||||
return Err(KEY_IN_BOOKS.to_string());
|
||||
}
|
||||
// Writing: a git repository must ignore the vault, or its records
|
||||
// would be committed (and could never be purged).
|
||||
let books = books_dir(store);
|
||||
let books = self.layout.books_dir(store);
|
||||
if create && books.join(".git").exists() {
|
||||
let ignored = std::fs::read_to_string(books.join(".gitignore"))
|
||||
.unwrap_or_default()
|
||||
@@ -882,7 +1034,7 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
// No message names the key file or the books' folders.
|
||||
ledger_vault::Vault::open(&Self::vault_dir(store), &key, create).map_err(|e| match e {
|
||||
ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e {
|
||||
ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager, or set [vault] key_file in the settings)".to_string(),
|
||||
ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(),
|
||||
ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()),
|
||||
@@ -1732,13 +1884,23 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
|
||||
/// Where these books keep what an import must never read
|
||||
/// (local_files.rs): the data dir, the books dir, the vault, the key file
|
||||
/// in force and the default one, and the home folder.
|
||||
fn places(store: &Store, settings: &Settings) -> Places {
|
||||
fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places {
|
||||
Places {
|
||||
data_dir: store.data_dir().to_path_buf(),
|
||||
books_dir: books_dir(store),
|
||||
vault_dir: BooksServer::vault_dir(store),
|
||||
key_files: vec![settings.key_file(), ledger_vault::default_key_file()],
|
||||
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
|
||||
books_dir: layout.books_dir(store),
|
||||
vault_dir: layout.vault_dir(store),
|
||||
key_files: vec![
|
||||
layout.key_file(store, settings),
|
||||
settings.key_file(),
|
||||
ledger_vault::default_key_file(),
|
||||
],
|
||||
// In the data root the home comes from the account's record, as
|
||||
// the root's own does; never only from the environment.
|
||||
home: match layout {
|
||||
Layout::Root(_) => ledger_store::root::account_home().ok(),
|
||||
Layout::BooksDir => None,
|
||||
}
|
||||
.or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1750,9 +1912,10 @@ fn places(store: &Store, settings: &Settings) -> Places {
|
||||
pub fn guard_import_roots(
|
||||
store: &Store,
|
||||
settings: &Settings,
|
||||
layout: &Layout,
|
||||
roots: ImportRoots,
|
||||
) -> Result<ImportRoots, String> {
|
||||
let places = places(store, settings);
|
||||
let places = places(store, settings, layout);
|
||||
let explicit = roots.explicit();
|
||||
let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out());
|
||||
if let Some((root, why)) = refused.first() {
|
||||
@@ -1775,12 +1938,12 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
|
||||
|
||||
/// Where a tool may write a file holding a full TIN: a new file, in a
|
||||
/// folder that exists, outside the books dir (which git commits).
|
||||
fn outside_books(store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
fn outside_books(books: &std::path::Path, path: &str) -> Result<std::path::PathBuf, String> {
|
||||
let p = std::path::PathBuf::from(path.trim());
|
||||
if path.trim().is_empty() {
|
||||
return Err("give a path to write to".into());
|
||||
}
|
||||
if is_inside(&books_dir(store), &p) {
|
||||
if is_inside(books, &p) {
|
||||
return Err(INSIDE_BOOKS.to_string());
|
||||
}
|
||||
match p.parent() {
|
||||
@@ -1804,7 +1967,7 @@ const EXISTS: &str = "a file is there already: name a new file";
|
||||
/// folder. A new file, in a folder that exists; with TINs, outside the
|
||||
/// books dir (which git commits, `exports/` included).
|
||||
fn package_dest(
|
||||
store: &Store,
|
||||
books: &std::path::Path,
|
||||
to: &str,
|
||||
file_name: &str,
|
||||
tins: bool,
|
||||
@@ -1827,7 +1990,7 @@ fn package_dest(
|
||||
));
|
||||
};
|
||||
if tins {
|
||||
return outside_books(store, &dest.to_string_lossy()).map_err(|e| {
|
||||
return outside_books(books, &dest.to_string_lossy()).map_err(|e| {
|
||||
format!("{e} (the CPA package holds full TINs; save it outside the books)")
|
||||
});
|
||||
}
|
||||
@@ -4880,7 +5043,7 @@ impl BooksServer {
|
||||
let dest = if p.include_tins {
|
||||
self.tin_file_dest(&store, path)?
|
||||
} else {
|
||||
outside_books(&store, path)?
|
||||
outside_books(&self.layout.books_dir(&store), path)?
|
||||
};
|
||||
let mut csv =
|
||||
String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
|
||||
@@ -4907,7 +5070,7 @@ impl BooksServer {
|
||||
csv.push('\n');
|
||||
}
|
||||
if p.include_tins {
|
||||
Self::wrote_tins(&store, csv.as_bytes())?;
|
||||
self.wrote_tins(&store, csv.as_bytes())?;
|
||||
}
|
||||
write_private_new(&dest, csv.as_bytes())?;
|
||||
Some(dest.display().to_string())
|
||||
@@ -6967,7 +7130,7 @@ impl BooksServer {
|
||||
.as_ref()
|
||||
.is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4()));
|
||||
let mut tin_facts: Option<ledger_core::TinFacts> = None;
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let facts = match facts {
|
||||
@@ -7028,7 +7191,7 @@ impl BooksServer {
|
||||
// never filed into the books (tin_files.rs INVARIANT
|
||||
// 1). Only now: every check has passed.
|
||||
if let Some(w) = &w9_file {
|
||||
Self::wrote_tins(&store, &w.bytes)?;
|
||||
self.wrote_tins(&store, &w.bytes)?;
|
||||
}
|
||||
let tin_hash = match &tin {
|
||||
Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?),
|
||||
@@ -7044,10 +7207,14 @@ impl BooksServer {
|
||||
None => w9_hash,
|
||||
}
|
||||
} else if held_tin {
|
||||
ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
ledger_vault::Vault::open(
|
||||
&vault_dir,
|
||||
&self.layout.key_file(&store, &self.settings()),
|
||||
false,
|
||||
)
|
||||
.ok()
|
||||
.and_then(|v| v.record_hash(id).ok().flatten())
|
||||
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
|
||||
} else if held_w9 {
|
||||
existing.as_ref().and_then(|e| e.vault_record.clone())
|
||||
} else {
|
||||
@@ -7215,7 +7382,7 @@ impl BooksServer {
|
||||
"the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path"
|
||||
)
|
||||
})?;
|
||||
Self::wrote_tins(&store, &file.bytes)?;
|
||||
self.wrote_tins(&store, &file.bytes)?;
|
||||
write_private_new(&dest, &file.bytes)?;
|
||||
Ok(Json(GetW9Out {
|
||||
party_id: id,
|
||||
@@ -7270,7 +7437,7 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = store.ledger().get_party(id).expect("resolved").clone();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
let (held_tin, held_w9) =
|
||||
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
|
||||
let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some())
|
||||
@@ -8279,7 +8446,15 @@ impl BooksServer {
|
||||
let dest = p
|
||||
.save_to
|
||||
.as_deref()
|
||||
.map(|to| package_dest(&store, to, &file_name, p.include_tins))
|
||||
.map(|to| {
|
||||
package_dest(
|
||||
&self.layout.books_dir(&store),
|
||||
to,
|
||||
&file_name,
|
||||
// In the data root nothing is ever written, TINs or not.
|
||||
p.include_tins || matches!(*self.layout, Layout::Root(_)),
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
if p.include_tins
|
||||
&& let Some(d) = &dest
|
||||
@@ -8653,7 +8828,7 @@ impl BooksServer {
|
||||
files.push(k1);
|
||||
}
|
||||
let seq = store.journal_seq();
|
||||
let vault_dir = Self::vault_dir(&store);
|
||||
let vault_dir = self.vault_dir(&store);
|
||||
drop(store);
|
||||
files.push((
|
||||
"general-journal.iif".into(),
|
||||
@@ -9385,6 +9560,11 @@ impl ServerHandler for BooksServer {
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Closing: refused as a protocol error, so a client knows it
|
||||
// didn't run (SharedStore INVARIANT 3).
|
||||
if self.store.closing() {
|
||||
return Err(not_run_closing());
|
||||
}
|
||||
// Pick up an edited settings file before the tool runs.
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
@@ -9402,6 +9582,9 @@ impl ServerHandler for BooksServer {
|
||||
// plain refusal, redacted like every other.
|
||||
let result = match result {
|
||||
Ok(out) => out,
|
||||
// Not run because the books are closing: a protocol error, so
|
||||
// the client knows (SharedStore INVARIANT 3).
|
||||
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e),
|
||||
Err(e) => Ok(rmcp::model::CallToolResponse::Complete(
|
||||
rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text(
|
||||
e.message.to_string(),
|
||||
@@ -9421,19 +9604,14 @@ impl ServerHandler for BooksServer {
|
||||
.sync(store.ledger().settings(), self.store.currency.code());
|
||||
}
|
||||
self.live().refresh();
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.enable_resources()
|
||||
.build(),
|
||||
)
|
||||
// The rules in brief, with the policy these books actually use;
|
||||
// the rest is in the handbook.
|
||||
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
ServerConfig::new(crate::opening::capabilities())
|
||||
// The rules in brief, with the policy these books actually use;
|
||||
// the rest is in the handbook.
|
||||
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
|
||||
.with_server_info(Implementation::new(
|
||||
"wanderbooks",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
//! base currency, and a vault key file given on the command line are
|
||||
//! fixed when the server starts (the key file given wins over any
|
||||
//! file's [vault] key_file).
|
||||
//! 6. A book in the data root (plan 3.4) has no settings file at all:
|
||||
//! its settings are its own, the defaults until the first is set,
|
||||
//! and no file is ever read for it.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
@@ -178,6 +181,9 @@ pub struct Live {
|
||||
/// Whether the settings files may be read: false once the books keep
|
||||
/// their own (INVARIANT 1).
|
||||
read_files: bool,
|
||||
/// A book in the data root: its settings are only ever its own
|
||||
/// (INVARIANT 6).
|
||||
own: bool,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
@@ -212,6 +218,26 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a book in the data root (INVARIANT 6): no settings file, now
|
||||
/// or later; `sync` brings the books' settings in once they hold any.
|
||||
pub fn own() -> Self {
|
||||
Self {
|
||||
current: Arc::new(Settings {
|
||||
journaled: true,
|
||||
..Settings::default()
|
||||
}),
|
||||
source: None,
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 0,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +255,7 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: true,
|
||||
own: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +276,7 @@ impl Live {
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +324,7 @@ impl Live {
|
||||
|
||||
/// Whether the settings in force are the books' own.
|
||||
pub fn is_journaled(&self) -> bool {
|
||||
self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
|
||||
@@ -1166,7 +1166,9 @@ impl BooksServer {
|
||||
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let s = store.ledger().settings();
|
||||
if !s.is_journaled() {
|
||||
// Books in the data root read no file: theirs are the defaults
|
||||
// until the first is set.
|
||||
if !s.is_journaled() && !self.live().status().journaled {
|
||||
return Err(
|
||||
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
|
||||
.into(),
|
||||
|
||||
+196
-36
@@ -58,13 +58,19 @@ pub const BOOKS_DIR: &str = "books";
|
||||
pub const RUN_DIR: &str = "run";
|
||||
/// Old copies moved aside at adoption, never deleted (plan 5.5).
|
||||
pub const LEGACY_DIR: &str = "legacy";
|
||||
/// A book's TIN vault folder, in its own folder.
|
||||
pub const VAULT_DIR: &str = "vault";
|
||||
/// A book's key folder, in its own folder.
|
||||
pub const KEYS_DIR: &str = "keys";
|
||||
/// The v1 vault key, in a book's `keys/` (P2 to P7).
|
||||
pub const LEGACY_KEY_FILE: &str = "legacy.key";
|
||||
/// Held while books are created or adopted, so only one is made at a time.
|
||||
const BOOKS_LOCK: &str = "books.lock";
|
||||
/// Staging folders under `books/` start so; none is a valid book id.
|
||||
const STAGING_PREFIX: &str = ".adopt-";
|
||||
|
||||
/// How the root is named in messages (INVARIANT 6).
|
||||
const THE_ROOT: &str = "the books' private folder";
|
||||
pub const THE_ROOT: &str = "the books' private folder";
|
||||
|
||||
/// Why a root or book operation was refused. No message names the root or
|
||||
/// a path inside it (INVARIANT 6).
|
||||
@@ -244,6 +250,91 @@ pub fn default_root(home: &Path) -> PathBuf {
|
||||
platform_data_dir(home).join(ROOT_NAME)
|
||||
}
|
||||
|
||||
/// Whether `path` is a data root or inside one (any folder above it, or
|
||||
/// it, holds the marker `setup` leaves). Books there belong to the books
|
||||
/// server alone (plan 3.1: `dev-stdio` refuses them).
|
||||
pub fn inside_a_root(path: &Path) -> bool {
|
||||
// From here (a path that is already absolute stays as it is).
|
||||
let path = std::env::current_dir()
|
||||
.map(|here| here.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
// Where the deepest folder that exists really is: a folder not made
|
||||
// yet holds no marker, and a link counts as where it leads.
|
||||
let Some(existing) = path.ancestors().find(|dir| dir.exists()) else {
|
||||
return false;
|
||||
};
|
||||
let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
|
||||
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
|
||||
}
|
||||
|
||||
/// A folder directly in a book's own folder (`keys/`, `vault/`): made
|
||||
/// 0700 if missing, made 0700 again if it is ours but others could enter
|
||||
/// it; refused if it is a symlink or belongs to another user. The error
|
||||
/// names no path (INVARIANT 6).
|
||||
pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootError> {
|
||||
let dir = book_dir.join(name);
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) => {
|
||||
let open = check_private_dir(&meta, true, "a folder inside the books")?;
|
||||
tighten_if(&dir, open)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
make_private_dir(&dir)?;
|
||||
sync_dir(book_dir)?;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The longest socket path used as it is: a Unix socket's address holds
|
||||
/// 104 bytes on macOS and 108 on Linux, the terminating zero included.
|
||||
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).
|
||||
#[derive(Debug)]
|
||||
pub struct SocketPath {
|
||||
path: PathBuf,
|
||||
_dir: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl SocketPath {
|
||||
/// The socket `name` in `dir`.
|
||||
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let full = dir.join(name);
|
||||
if full.as_os_str().len() <= SOCKET_PATH_MAX {
|
||||
return Ok(Self {
|
||||
path: full,
|
||||
_dir: None,
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
let handle = std::fs::File::open(dir)?;
|
||||
let path = PathBuf::from(format!("/proc/self/fd/{}/{name}", handle.as_raw_fd()));
|
||||
Ok(Self {
|
||||
path,
|
||||
_dir: Some(handle),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
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`",
|
||||
))
|
||||
}
|
||||
|
||||
/// The path to bind or connect to.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// The data root.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Root {
|
||||
@@ -422,6 +513,14 @@ impl Root {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The root's `run/` folder (its sockets and locks), made 0700 if
|
||||
/// missing and refused if it isn't private.
|
||||
pub fn run_dir(&self) -> Result<PathBuf, RootError> {
|
||||
self.private_subdir(RUN_DIR, false)
|
||||
.map(|(dir, _)| dir)
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
|
||||
/// The folder a book lives in.
|
||||
pub fn book_dir(&self, id: &BookId) -> PathBuf {
|
||||
self.path.join(BOOKS_DIR).join(id.as_str())
|
||||
@@ -562,22 +661,7 @@ impl Root {
|
||||
if std::fs::symlink_metadata(&dest).is_ok() {
|
||||
return Err(RootError::Exists(id.to_string()));
|
||||
}
|
||||
let source = std::fs::canonicalize(source).map_err(|e| {
|
||||
RootError::Adopt(format!("can't read the books at {}: {e}", source.display()))
|
||||
})?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
let journal_path = source.join(JOURNAL_FILE);
|
||||
if !journal_path.is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books at {}: it has no {JOURNAL_FILE}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
let source = self.adoptable(source)?;
|
||||
for (what, path) in [
|
||||
("vault folder", &options.vault_dir),
|
||||
("vault key", &options.vault_key),
|
||||
@@ -586,8 +670,7 @@ impl Root {
|
||||
&& !path.exists()
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"the {what} given, {}, isn't there: nothing was adopted",
|
||||
path.display()
|
||||
"the {what} given isn't there: nothing was adopted"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -595,16 +678,17 @@ impl Root {
|
||||
// writes it while it is copied.
|
||||
let (_source_lock, source_lock_created) = lock_source(&source)?;
|
||||
// Step 2: verify the chain and record the head.
|
||||
let raw = std::fs::read(&journal_path)?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
let (raw, lines) = read_source_journal(&source)?;
|
||||
let head = head_of(&lines);
|
||||
if let Some(expected) = &options.expect_head
|
||||
&& *expected != head
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books changed since the preview (they are now at event {}): nothing was \
|
||||
adopted; preview them again",
|
||||
head.0
|
||||
)));
|
||||
}
|
||||
let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set");
|
||||
if !keeps_settings && options.settings.is_none() {
|
||||
return Err(RootError::Adopt(
|
||||
@@ -634,14 +718,14 @@ impl Root {
|
||||
let copied = copy_blobs(&source.join(blob_dir), &staging.join(blob_dir))?;
|
||||
// Step 5: the vault's records and the v1 key.
|
||||
let vault_files = match &options.vault_dir {
|
||||
Some(dir) => copy_flat_private(dir, &staging.join("vault"))?,
|
||||
Some(dir) => copy_flat_private(dir, &staging.join(VAULT_DIR))?,
|
||||
None => 0,
|
||||
};
|
||||
let key_copied = match &options.vault_key {
|
||||
Some(key) => {
|
||||
let keys = staging.join("keys");
|
||||
let keys = staging.join(KEYS_DIR);
|
||||
make_private_dir(&keys)?;
|
||||
write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?;
|
||||
write_private_file(&keys.join(LEGACY_KEY_FILE), &std::fs::read(key)?)?;
|
||||
sync_dir(&keys)?;
|
||||
true
|
||||
}
|
||||
@@ -708,6 +792,79 @@ impl Root {
|
||||
}
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The source of an adoption, made absolute: books outside the root
|
||||
/// (and not holding it) with a journal.
|
||||
fn adoptable(&self, source: &Path) -> Result<PathBuf, RootError> {
|
||||
let source = std::fs::canonicalize(source)
|
||||
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
if !source.join(JOURNAL_FILE).is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books there: it has no {JOURNAL_FILE}"
|
||||
)));
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// What adopting `source` would start from: its head, its base
|
||||
/// currency, and whether it keeps its own settings, read under the
|
||||
/// checks [`Self::adopt`] makes, writing nothing (not even a lock
|
||||
/// file). The adoption itself reads it all again under the lock.
|
||||
pub fn adopt_preview(&self, source: &Path) -> Result<SourcePreview, RootError> {
|
||||
(|| {
|
||||
let source = self.adoptable(source)?;
|
||||
// Held for writing now: adoption would be refused, so is this.
|
||||
if let Ok(lock) = std::fs::File::open(source.join(crate::store::LOCK_FILE))
|
||||
&& let Err(std::fs::TryLockError::WouldBlock) = lock.try_lock_shared()
|
||||
{
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
let (_, lines) = read_source_journal(&source)?;
|
||||
Ok(SourcePreview {
|
||||
head: head_of(&lines),
|
||||
currency: crate::store::journal_currency(&lines)?,
|
||||
keeps_settings: lines.iter().any(|l| l.line_type == "settings.set"),
|
||||
})
|
||||
})()
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's journal, whole and verified: its bytes and lines.
|
||||
fn read_source_journal(source: &Path) -> Result<(Vec<u8>, Vec<crate::JournalLine>), RootError> {
|
||||
let raw = std::fs::read(source.join(JOURNAL_FILE))?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
Ok((raw, lines))
|
||||
}
|
||||
|
||||
/// What [`Root::adopt_preview`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SourcePreview {
|
||||
/// The source's head, `(seq, hash)`.
|
||||
pub head: (u64, String),
|
||||
/// Its base currency.
|
||||
pub currency: Currency,
|
||||
/// Whether it keeps its own settings.
|
||||
pub keeps_settings: bool,
|
||||
}
|
||||
|
||||
/// Options for [`Root::adopt`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AdoptOptions {
|
||||
@@ -725,6 +882,9 @@ pub struct AdoptOptions {
|
||||
/// The v1 vault key file: copied to the book's `keys/legacy.key`.
|
||||
/// Given, it must exist.
|
||||
pub vault_key: Option<PathBuf>,
|
||||
/// The head the source must have, `(seq, hash)`, as a preview read
|
||||
/// it: if the books changed since, nothing is adopted.
|
||||
pub expect_head: Option<(u64, String)>,
|
||||
}
|
||||
|
||||
/// What [`Root::adopt`] did.
|
||||
@@ -779,11 +939,11 @@ fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
return Err(RootError::from(StoreError::Locked(format!(
|
||||
"another wanderbooks process has the books in {} open for writing (an agent \
|
||||
session, or its MCP server); close it first",
|
||||
source.display()
|
||||
))));
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Err(e.into()),
|
||||
}
|
||||
|
||||
@@ -6235,7 +6235,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
|
||||
/// The base currency of a journal: its first line's, which must be a
|
||||
/// sound `store.created` ([`created_currency`]).
|
||||
fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
let first = lines
|
||||
.first()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("journal has no store.created line".into()))?;
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn command_path(data_dir: &Path) -> PathBuf {
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
let path = command_path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
private_folder(dir)?;
|
||||
}
|
||||
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
|
||||
let cmd = ViewerCommand {
|
||||
@@ -129,6 +129,42 @@ pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerC
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// The folder the requests go in: made private (0700) if missing, made
|
||||
/// private again if ours; refused if it is a link or another account's
|
||||
/// (in a shared temp folder anyone could have planted one, to have a
|
||||
/// request written through it).
|
||||
fn private_folder(dir: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
if let Some(parent) = dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) => {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
let me = unsafe { libc::geteuid() };
|
||||
if !meta.is_dir() || meta.uid() != me {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the viewer's request folder isn't this account's own",
|
||||
));
|
||||
}
|
||||
// Private again, whatever it was.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(dir)?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
|
||||
/// The latest request for `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
let bytes = std::fs::read(command_path(data_dir)).ok()?;
|
||||
@@ -170,6 +206,26 @@ mod tests {
|
||||
);
|
||||
// Same books, same file; other books, another.
|
||||
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// Made private; made private again.
|
||||
let folder = base.join("wanderbooks");
|
||||
private_folder(&folder).unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
private_folder(&folder).unwrap();
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
// A link planted in its place is refused.
|
||||
let link = base.join("planted");
|
||||
std::os::unix::fs::symlink(&base, &link).unwrap();
|
||||
assert!(private_folder(&link).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
|
||||
let _ = std::fs::remove_file(command_path(&dir));
|
||||
}
|
||||
|
||||
@@ -316,6 +316,8 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
|
||||
settings: None,
|
||||
vault_dir: Some(vault.clone()),
|
||||
vault_key: Some(key.clone()),
|
||||
// As a preview read it: the same head, so it goes ahead.
|
||||
expect_head: Some(source_head.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -724,8 +726,16 @@ fn adoption_leaves_a_read_only_source_as_it_was() {
|
||||
drop(source_books(old.path(), true));
|
||||
let before = snapshot_files(old.path());
|
||||
chmod_tree(old.path(), 0o500, 0o400);
|
||||
// Writable again however the test ends, so its folder can go.
|
||||
struct Writable<'a>(&'a Path);
|
||||
impl Drop for Writable<'_> {
|
||||
fn drop(&mut self) {
|
||||
chmod_tree(self.0, 0o700, 0o600);
|
||||
}
|
||||
}
|
||||
let writable = Writable(old.path());
|
||||
let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default());
|
||||
chmod_tree(old.path(), 0o700, 0o600);
|
||||
drop(writable);
|
||||
let report = report.unwrap();
|
||||
assert!(!report.source_lock_created);
|
||||
assert_eq!(snapshot_files(old.path()), before, "the lock file included");
|
||||
@@ -1099,3 +1109,178 @@ fn errors_other_than_not_found_are_never_taken_for_it() {
|
||||
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adoption_preview_reads_the_source_and_writes_nothing() {
|
||||
let home = tmp("preview");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("preview-src");
|
||||
let s = source_books(old.path(), false);
|
||||
let head = s.head();
|
||||
drop(s);
|
||||
// No lock file beside the journal: the preview must not make one.
|
||||
let _ = std::fs::remove_file(old.path().join("wanderbooks.lock"));
|
||||
let before = snapshot_files(old.path());
|
||||
let found = root.adopt_preview(old.path()).unwrap();
|
||||
assert_eq!(found.head, head);
|
||||
assert_eq!(found.currency, Currency::USD);
|
||||
assert!(!found.keeps_settings);
|
||||
assert_eq!(
|
||||
snapshot_files(old.path()),
|
||||
before,
|
||||
"the preview wrote to the source"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
// Books that keep their own settings say so.
|
||||
let kept = tmp("preview-kept");
|
||||
drop(source_books(kept.path(), true));
|
||||
assert!(root.adopt_preview(kept.path()).unwrap().keeps_settings);
|
||||
// The preview refuses what adoption refuses, naming no root.
|
||||
let book = root
|
||||
.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap();
|
||||
drop(book);
|
||||
let root_text = root.path().display().to_string();
|
||||
for bad in [
|
||||
root.book_dir(&BookId::parse("acme").unwrap()),
|
||||
root.path().to_path_buf(),
|
||||
home.path().join("missing"),
|
||||
home.path().to_path_buf(),
|
||||
] {
|
||||
let e = root.adopt_preview(&bad).unwrap_err();
|
||||
assert!(matches!(e, RootError::Adopt(_)), "{bad:?}: {e:?}");
|
||||
assert!(!e.to_string().contains(&root_text), "{e}");
|
||||
}
|
||||
// A cut-off last line is refused, as adoption refuses it.
|
||||
let cut = tmp("preview-cut");
|
||||
drop(source_books(cut.path(), false));
|
||||
let journal = cut.path().join(JOURNAL_FILE);
|
||||
let mut raw = std::fs::read(&journal).unwrap();
|
||||
raw.pop();
|
||||
std::fs::write(&journal, raw).unwrap();
|
||||
assert!(
|
||||
matches!(root.adopt_preview(cut.path()), Err(RootError::Adopt(why)) if why.contains("cut off"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_folder_is_made_private_and_a_shared_one_refused() {
|
||||
let home = tmp("bookfolder");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
drop(
|
||||
root.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap(),
|
||||
);
|
||||
let book = root.book_dir(&BookId::parse("acme").unwrap());
|
||||
let keys = ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(keys, book.join("keys"));
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
// Again: already there, fine.
|
||||
assert_eq!(
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap(),
|
||||
keys
|
||||
);
|
||||
// Ours, but others could enter it: made private again.
|
||||
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
std::os::unix::fs::symlink(home.path(), book.join("vault")).unwrap();
|
||||
assert!(ledger_store::root::private_book_folder(&book, "vault").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_run_folder_is_private() {
|
||||
let home = tmp("run");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let run = root.run_dir().unwrap();
|
||||
assert_eq!(run, root.path().join("run"));
|
||||
assert_eq!(mode(&run), 0o700);
|
||||
std::fs::remove_dir(&run).unwrap();
|
||||
assert_eq!(mode(&root.run_dir().unwrap()), 0o700, "remade private");
|
||||
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(matches!(root.run_dir(), Err(RootError::Unsafe(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_root_is_known_by_its_marker() {
|
||||
let home = tmp("inside");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
use ledger_store::root::inside_a_root;
|
||||
assert!(inside_a_root(root.path()));
|
||||
assert!(inside_a_root(&root.path().join("books/acme")));
|
||||
assert!(inside_a_root(&root.path().join("books/not/yet/made")));
|
||||
assert!(!inside_a_root(home.path()));
|
||||
// A path relative to where the process runs is taken from there.
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let up = "../".repeat(here.components().count() - 1);
|
||||
let relative = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
root.path()
|
||||
.join("books/acme")
|
||||
.display()
|
||||
.to_string()
|
||||
.trim_start_matches('/')
|
||||
));
|
||||
assert!(relative.is_relative());
|
||||
assert!(inside_a_root(&relative));
|
||||
let outside = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
home.path().display().to_string().trim_start_matches('/')
|
||||
));
|
||||
assert!(!inside_a_root(&outside));
|
||||
assert!(!inside_a_root(&home.path().join("elsewhere")));
|
||||
// Through a link, too: it is where the path leads that counts.
|
||||
let link = home.path().join("link");
|
||||
std::os::unix::fs::symlink(root.path().join("books"), &link).unwrap();
|
||||
assert!(inside_a_root(&link));
|
||||
assert!(!root.path().join(MARKER_FILE).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_that_changed_since_the_preview_are_not_adopted() {
|
||||
let home = tmp("expect");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("expect-src");
|
||||
drop(source_books(old.path(), true));
|
||||
let previewed = root.adopt_preview(old.path()).unwrap().head;
|
||||
// The old server writes one more event after the preview.
|
||||
let mut s = Store::open(old.path()).unwrap();
|
||||
s.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let now = s.head();
|
||||
// While it holds them, a preview is refused as adoption would be.
|
||||
assert!(matches!(
|
||||
root.adopt_preview(old.path()),
|
||||
Err(RootError::Store(_, StoreError::Locked(_)))
|
||||
));
|
||||
drop(s);
|
||||
let before = snapshot_files(old.path());
|
||||
let e = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(previewed),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, RootError::Adopt(why) if why.contains("changed since the preview")),
|
||||
"{e:?}"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
assert_eq!(snapshot_files(old.path()), before);
|
||||
// Previewed again, it goes ahead.
|
||||
let report = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(now.clone()),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.source_head, now);
|
||||
}
|
||||
+7
-3
@@ -297,13 +297,17 @@ The books are open while something holds them; there is no background service. T
|
||||
- `run/mcp.sock`: served by the **server**, MCP for the bridge. Local agents never use TCP, so no other account on the machine can squat a port and catch the token or the traffic. Both ends check the peer's user ID (`SO_PEERCRED`/`getpeereid`, the pipe's SID). TCP is off by default. The owner can turn it on (`server.toml`, or `wanderbooks serve --http`) for a client that needs HTTP; it then binds 127.0.0.1:7411, a failed bind is reported by `doctor` and is not fatal, and such clients hold no lease, so the books must already be open.
|
||||
- `run/admin.sock`: served by the server, admin only (create, adopt, tokens), as in 3.1. Neither the viewer nor the bridge opens it.
|
||||
|
||||
**Starting the host cleanly.** Every mode that confines itself (the viewer, the bridge) first starts a small unconfined **helper** (`wanderbooks helper`, spawned as its own process over an inherited socket pair before any thread, window or Cocoa start; never a fork of a running GUI), then confines itself. The helper accepts a fixed message set and nothing else: `start_host`; `open_with` an attachment id (the helper itself copies that blob, read-only, into a 0700 per-user directory, `$XDG_RUNTIME_DIR/wanderbooks-open/` or its per-system equivalent, swept when the helper exits, and hands it to the system's default app; it never accepts a path or a command line); `stage_backup`, whose file the helper asks the owner for in its own file dialog (never a path from the viewer), copying it into the data root's `uploads/` and returning a staged id for the `restore` intent; and `start_updater`, with no arguments. Only the helper starts the host, by a clean re-exec: environment scrubbed to a fixed allow-list (keeping `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`, which `systemd-run --user` needs, only after checking they point at the host's own user's runtime directory), stdin, stdout and stderr to null (a log file under `run/`), a new session (`setsid`), a fixed umask. On Linux under systemd (uwsm on Omarchy puts every app in its own scope, which would take the host down with the app) it re-execs through a transient `systemd-run --user --scope`, which installs nothing, persists nothing, and disappears with the host; elsewhere `setsid` alone. On Windows, `CREATE_BREAKAWAY_FROM_JOB` where the job allows it, and `DETACHED_PROCESS`. The host resolves the data root itself from the user's account (`getpwuid` home, or the known folder on Windows), never from the opener's environment, unless `wanderbooks setup` recorded another root. It refuses to run if it finds itself confined (seccomp in `/proc/self/status`, a Landlock write probe).
|
||||
**The server's process (P2 slice 3).** The server is the hidden mode `wanderbooks server`, started by the host with a pipe on its standard input; it runs until that input ends (the host let go of it, or died) or it gets SIGTERM, SIGINT or SIGHUP (watched from before the books are opened). It takes `run/server.lock` before anything else (a second server waits for it, touching nothing, and leaves if told to close first), opens and verifies every book (a book that won't open is listed with why, and the others are served), binds both sockets, and only then prints `ready` on its standard output. `run/mcp.sock` speaks Streamable HTTP over the Unix socket, `/b/{id}/mcp` per book, so local and remote share one code path (3.2); `run/admin.sock` takes one JSON line and answers one (`list`, `create`, `adopt`). A socket path longer than the system allows is reached through `/proc/self/fd` on Linux. Closing: new requests are refused, unrun (HTTP 503 at the door, or, for a call already past it, JSON-RPC error -32050 "closing: this call wasn't run"; the bridge sends either again once the books are back); every request already running finishes its work, however long, whether or not its client is still there; answers on their way out get 10 s more (a second signal skips that wait, never the first); then the sessions end and the sockets go. Until uploads have their own route (slice 5) a request may be 64 MiB, since receipts still ride in tool calls. A book in the root keeps its vault in its own `vault/` and its v1 key in its own `keys/legacy.key` (0700 and 0600, made private whenever first written), and no tool writes a file anywhere in the root. Adoption is a preview, then a confirm tied to the head the preview read; entries of a settings file the books don't know are refused unless skipped, its comments are shown, the vault key it names must be the one given, and the key must open every vault record. Open for slice 8: under Landlock the server can't read an adoption's source outside the root, so adoption needs another way in by then.
|
||||
|
||||
**The host's process (P2 slice 3).** `wanderbooks host` closes every descriptor above 2 it was handed, refuses to run confined, takes `run/host.lock` (waiting up to 30 s for a host that is closing; one that finds a live host exits at once), binds `run/lease.sock`, and starts the server from `/proc/self/exe`, naming the root on its command line only when the host itself was given one (tests): nothing in a process list names it. Ending leases, it tells each holder why and closes the server only once they have let go (at most 2 s), so holders stop sending before it closes; a call already on its way is refused at the door, unrun, and sent again once the books are back. A closing server is never killed, however long its last call runs; a new server waits for an old one still finishing (its host killed) rather than fail. A restart begins when the new version appears, not at a quiet moment: the server first finishes every call in progress (none is cut off), and calls arriving meanwhile wait for the new server (up to 60 s); waiting for idle comes with jobs (`books.job`), which are what can run long. On a restart the old host hands over: it lets go of `host.lock` and keeps `update.lock` until the new host answers, so waiting holders find it. A holder starts a host only when none holds the lock; one that died at once is noticed within 2 s; a server that fails soon after opening counts as failing (only 60 s open clears the count). Lease records carry the holder's process as the kernel reports it. The lease reasons are `closed_by_owner`, `restarting`, `closing` (the host is closing because nothing held it: hold again, a new host will answer; the owner's close, by the menu, `wanderbooks close` or a signal to the host, is always `closed_by_owner`, and a holder that had held stays closed) and `server_failed`. The bridge never sends a call the agent cancelled before it was sent (MCP owes no answer to a cancelled request), answers a ping itself while the books open, refuses JSON-RPC batches request by request, passes no client offer it can't carry back (sampling, elicitation, roots), reports a call answered with nothing as possibly run, and carries the per-request protocol headers of the 2026-07-28 era. Sessions on the server time out only after 24 h without a message (rmcp's idle timer ignores a call in progress, so a shorter one could cut a long call off); the bridge ends its own when its stdin closes or it is signalled. `wanderbooks doctor` (a first form, completed in slice 8) says whether the books are open, what holds them, and the host's and server's last log lines, naming no folder. Tests shorten the timings with a hidden `--test-knobs` flag, passed down from holder to helper to host.
|
||||
|
||||
**Starting the host cleanly.** Every mode that confines itself (the viewer, the bridge) first starts a small unconfined **helper** (`wanderbooks helper`, spawned as its own process over an inherited socket pair before any thread, window or Cocoa start; never a fork of a running GUI), then confines itself. The helper accepts a fixed message set and nothing else: `start_host`; `open_with` an attachment id (the helper itself copies that blob, read-only, into a 0700 per-user directory, `$XDG_RUNTIME_DIR/wanderbooks-open/` or its per-system equivalent, swept when the helper exits, and hands it to the system's default app; it never accepts a path or a command line); `stage_backup`, whose file the helper asks the owner for in its own file dialog (never a path from the viewer), copying it into the data root's `uploads/` and returning a staged id for the `restore` intent; and `start_updater`, with no arguments. A confined mode starts the host only through its helper; unconfined holders (the CLI commands, `serve`) use the same clean start directly, and a restarting host starts its successor itself, already clean, in its own session and scope (P2 slice 3). The clean start is a re-exec: environment scrubbed to a fixed allow-list (keeping `XDG_RUNTIME_DIR` and `DBUS_SESSION_BUS_ADDRESS`, which `systemd-run --user` needs, only after checking they point at the host's own user's runtime directory), stdin, stdout and stderr to null (a log file under `run/`), a new session (`setsid`), a fixed umask. On Linux under systemd (uwsm on Omarchy puts every app in its own scope, which would take the host down with the app) it re-execs through a transient `systemd-run --user --scope`, which installs nothing, persists nothing, and disappears with the host; elsewhere `setsid` alone. On Windows, `CREATE_BREAKAWAY_FROM_JOB` where the job allows it, and `DETACHED_PROCESS`. The host resolves the data root itself from the user's account (`getpwuid` home, or the known folder on Windows), never from the opener's environment, unless `wanderbooks setup` recorded another root. It refuses to run if it finds itself confined (seccomp in `/proc/self/status`, a Landlock write probe).
|
||||
|
||||
**One host, even in a race.** The host takes `run/host.lock` (an exclusive flock) before anything else; a second host exits and its opener attaches. The host starts the server, which takes every book's writer lock, and only then the archive process, so two archive pushers never meet on one `archive.git`. An opener that finds no live host and an unlocked `host.lock` starts one; one that finds a host shutting down (the lease socket refuses during shutdown) waits on `host.lock`, then starts a new one.
|
||||
|
||||
**Closing.** When the last lease ends, the host waits 10 s; a lease arriving in that time cancels the close. Then: no new requests, the one in progress finishes, every writer lock is released, the archive process finishes its push, and the host exits. "Close the books" (the app's menu, `wanderbooks close`) ends every lease at once; a bridge then tells its agent the owner closed the books and reopens them only in a new session. Before it ends leases the host says why on each one (`closed_by_owner`, `restarting`, `idle_close`). A holder that sees the connection end with no reason (a crash, a kill) starts a new host and re-takes its lease, with backoff (1, 2, 4 … 60 s, at most five tries); a host that keeps failing to start (a damaged journal, a panic) becomes a Needs-attention and `doctor` item, and a bridge answers its agent with a clear error instead of retrying forever. `host_respawns` and `respawn_backs_off` test it.
|
||||
**Closing.** When the last lease ends, the host waits 10 s; a lease arriving in that time cancels the close. Then: no new requests, the one in progress finishes, every writer lock is released, the archive process finishes its push, and the host exits. "Close the books" (the app's menu, `wanderbooks close`) ends every lease at once; a bridge then tells its agent the owner closed the books and reopens them only in a new session. Before it ends leases the host says why on each one (`closed_by_owner`, `restarting`, `closing`, `server_failed`; P2 slice 3). A holder that sees the connection end with no reason (a crash, a kill) starts a new host and re-takes its lease, with backoff (1, 2, 4 … 60 s, at most five tries); a host that keeps failing to start (a damaged journal, a panic) becomes a Needs-attention and `doctor` item, and a bridge answers its agent with a clear error instead of retrying forever. `host_respawns` and `respawn_backs_off` test it.
|
||||
|
||||
**Restarting** (an update, or the executable replaced by a package manager) is not a close. What lives between calls survives it: upload grants, download links, `/h/` sessions and finished job results are kept in `run/state/` (0600, the session key with them), and a running job delays the restart until it finishes. The host takes `run/update.lock`, closes the books as above but keeps the lease socket's holders informed, swaps (or, for a package-managed install, finds the new executable already in place), and restarts from the new executable. Openers that arrive meanwhile wait on `update.lock`. Bridges reconnect and re-initialize upstream without the agent noticing. A package-managed upgrade is noticed by the host (its executable's inode changed) and the restart happens the next time no request is in progress.
|
||||
**Restarting** (an update, or the executable replaced by a package manager) is not a close. What lives between calls survives it: upload grants, download links, `/h/` sessions and finished job results are kept in `run/state/` (0600, the session key with them), and a running job delays the restart until it finishes (jobs, `books.job`, come later; until then a restart begins at once and the server finishes every call in progress first, P2 slice 3). The host takes `run/update.lock`, closes the books as above but keeps the lease socket's holders informed, swaps (or, for a package-managed install, finds the new executable already in place), and restarts from the new executable. Openers that arrive meanwhile wait on `update.lock`. Bridges reconnect and re-initialize upstream without the agent noticing. A package-managed upgrade is noticed by the host (its executable's inode changed) and restarts it as above.
|
||||
|
||||
**One version at a time.** Children (server, archive, extractor) are started from the executable the host itself is running (`/proc/self/exe` or `fexecve` on Linux), never by path, so a swap mid-run can't mix versions. Where that isn't available (macOS, Windows) every child, bridge and lease handshake carries the version, and a mismatch triggers a restart. The lease, helper and bridge protocols carry protocol versions (not release versions, since a staged update can skip releases); each accepts its current version and the one before, and outside that range refuses with a plain message ("restart this agent session").
|
||||
|
||||
|
||||
Reference in new issue
Block a user