P2 slice 2: the data root (plan 3.4, 5.5)

ledger_store::root: one private folder for every book, found without the
environment (--root, else the root setup recorded in a file only this
account can write, else the platform default under the home from the
password database). Every folder made 0700, files 0600; a root that is a
link, another user's, open to others, unmarked, or under a folder another
user could swap is refused; no error names it. Setup keeps a recorded
root, never makes a second root or moves books while one holds any, and
leaves a folder holding other things untouched. Books are created and
adopted one at a time; adoption reads the source under its writer lock
(changing nothing in it), copies into staging, re-hashes every stored
file against its name, checks every named file is there, opens and
verifies the copy with the source's head, imports settings for books
that keep none, syncs, and only then renames it into place.
wanderbooks setup makes the root and never prints where it is. Three
review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-18 14:42:32 -06:00
1 parent e863bfee74
commit b47331e9d5
11 files changed
+2629 -10

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ gui = ["dep:ledger-gui"]
[dependencies]
ledger-mcp = { path = "../ledger-mcp" }
ledger-store = { path = "../ledger-store" }
ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
+77 -1
View File
@@ -5,6 +5,7 @@
//! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init)
//! wanderbooks gui [DIR | viewer flags]
//! wanderbooks dev-stdio [server flags]
//! wanderbooks setup [--root PATH] [--home DIR]
//! ```
//!
//! `wanderbooks` is the one executable (plan D-22): `gui` is the viewer and
@@ -392,10 +393,12 @@ fn print_usage() {
eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]");
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
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!(" 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");
@@ -795,6 +798,78 @@ fn run_gui(_args: Vec<String>) -> i32 {
2
}
/// `wanderbooks setup [--root PATH] [--home DIR]`: make the books'
/// private folder (plan 3.4): `--root` if given, else
/// `$XDG_DATA_HOME/wanderbooks`, else the platform's default under the
/// account's home (`--home`, else the system's user records). The folder's
/// location is never printed: nothing an agent is shown names it.
fn run_setup(args: Vec<String>) -> i32 {
let mut root: Option<PathBuf> = None;
let mut home: Option<PathBuf> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let slot = match arg.as_str() {
"--root" => &mut root,
"--home" => &mut home,
"--help" | "-h" => {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(
" --root where the books' private folder goes (default: your data folder)"
);
eprintln!(" --home the account's home (default: from the system's user records)");
return 0;
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
return 2;
}
};
match args.next() {
Some(value) => *slot = Some(PathBuf::from(value)),
None => {
eprintln!("error: {arg} needs a path");
return 1;
}
}
}
let account = ledger_store::root::account_home().ok();
let home = match home.or_else(|| account.clone()) {
Some(home) => home,
None => {
eprintln!("error: {}", ledger_store::root::RootError::NoHome);
return 1;
}
};
if account.as_ref() != Some(&home) {
eprintln!(
"note: --home isn't this account's home, so this account's books server won't \
look there (tests and unusual setups only)"
);
}
let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from);
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
Ok((_, report)) => {
let what = if report.created {
"made"
} else if report.tightened {
"already there; made private again (other users could open it)"
} else {
"already there"
};
println!("The books' private folder: {what}. Only your account can open it.");
if report.recorded {
println!("It isn't in the default place, so where it is was noted for next time.");
}
0
}
Err(e) => {
eprintln!("error: {e}");
1
}
}
}
/// `wanderbooks dev-stdio`: the books server over stdio, in this process.
fn run_dev_stdio(args: Vec<String>) -> i32 {
match ledger_mcp::run("wanderbooks dev-stdio", args) {
@@ -815,6 +890,7 @@ fn main() {
match all.next().as_deref() {
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())),
_ => {}
}
let mut dir: Option<PathBuf> = None;
@@ -845,7 +921,7 @@ fn main() {
}
}
}
if let Some(mode @ ("gui" | "dev-stdio")) = cmd.as_deref() {
if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
}
+124
View File
@@ -0,0 +1,124 @@
//! `wanderbooks setup`: the books' private folder, made from the command
//! line, with its location never printed.
#![cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
fn wanderbooks() -> Command {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
}
fn mode(path: &std::path::Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn setup_makes_the_private_folder_and_never_names_it() {
let home = tempfile::Builder::new()
.prefix("wb-setup-")
.tempdir()
.unwrap();
let root = home.path().join(".local/share/wanderbooks");
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("The books' private folder: made"), "{text}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("--home isn't this account's home"),
"{out:?}"
);
assert!(
!text.contains(home.path().to_str().unwrap()),
"names the folder: {text}"
);
assert!(!String::from_utf8_lossy(&out.stderr).contains(home.path().to_str().unwrap()));
assert_eq!(mode(&root), 0o700);
for sub in ["books", "run", "legacy"] {
assert_eq!(mode(&root.join(sub)), 0o700, "{sub}");
}
// Again: already there.
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("already there."),
"{out:?}"
);
// Others could open it: made private again.
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&out.stdout).contains("made private again"),
"{out:?}"
);
assert_eq!(mode(&root), 0o700);
}
#[test]
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
let home = tempfile::Builder::new()
.prefix("wb-setup-xdg-")
.tempdir()
.unwrap();
let xdg = home.path().join("xdg");
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env("XDG_DATA_HOME", &xdg)
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
"{out:?}"
);
assert_eq!(mode(&xdg.join("wanderbooks")), 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()
);
}
#[test]
fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
let out = wanderbooks().args(["setup", "--bogus"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
let out = wanderbooks().args(["setup", "--root"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(String::from_utf8_lossy(&out.stderr).contains("--root needs a path"));
let home = tempfile::Builder::new()
.prefix("wb-setup-link-")
.tempdir()
.unwrap();
let real = home.path().join("real");
std::fs::create_dir(&real).unwrap();
std::os::unix::fs::symlink(&real, home.path().join("link")).unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.arg("--root")
.arg(home.path().join("link"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("is a symlink"));
// A mode comes first.
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
}
+6
View File
@@ -17,12 +17,18 @@ thiserror = "2"
sha2 = "0.10"
hex = "0.4"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
proptest = "1"
rust_decimal = "1"
criterion = "0.8.2"
[target.'cfg(unix)'.dev-dependencies]
libc = "0.2"
[[bench]]
name = "store"
harness = false
+1
View File
@@ -24,6 +24,7 @@ pub mod format;
pub mod journal;
pub mod meta;
pub mod reconcilable;
pub mod root;
pub mod schema;
pub mod snapshot;
pub mod store;
File diff suppressed because it is too large. Load diff
+22 -6
View File
@@ -88,6 +88,12 @@ fn lock_books(data_dir: &Path) -> Result<std::fs::File, StoreError> {
Err(std::fs::TryLockError::Error(e)) => Err(StoreError::Io(e)),
}
}
/// Take a data dir's writer lock to copy it (adoption, `root.rs`): held,
/// no other writer can append while the copy is made.
pub(crate) fn lock_books_for_adoption(data_dir: &Path) -> Result<std::fs::File, StoreError> {
lock_books(data_dir)
}
pub(crate) const BLOBS_DIR: &str = "blobs";
/// One row of an entry listing.
@@ -366,8 +372,8 @@ impl Store {
let _ = std::fs::remove_file(db_path.with_extension("db-wal"));
let _ = std::fs::remove_file(db_path.with_extension("db-shm"));
eprintln!(
"wanderbooks: sqlite projection {why}; quarantined to {} and rebuilding from journal",
quarantine.display()
"wanderbooks: sqlite projection {why}; quarantined to {} beside it and rebuilding from journal",
quarantine.file_name().unwrap_or_default().to_string_lossy()
);
fresh_db = true;
}
@@ -401,9 +407,9 @@ impl Store {
)?;
apply_line_to_sqlite(&store.conn, &line)?;
eprintln!(
"wanderbooks: the journal's last line was cut off (an append that never finished); set aside {} bytes in {}",
"wanderbooks: the journal's last line was cut off (an append that never finished); set aside {} bytes in {} beside it",
t.bytes,
t.saved_as.display()
t.saved_as.file_name().unwrap_or_default().to_string_lossy()
);
}
Ok(store)
@@ -3709,8 +3715,8 @@ impl Store {
let keep = self.db_path.with_extension(format!("mismatch.{stamp}"));
std::fs::copy(&self.db_path, &keep)?;
eprintln!(
"wanderbooks: {why}; kept the old database as {} and rebuilt from the journal",
keep.display()
"wanderbooks: {why}; kept the old database as {} beside it and rebuilt from the journal",
keep.file_name().unwrap_or_default().to_string_lossy()
);
self.rebuild()?;
self.verify()?;
@@ -3729,6 +3735,16 @@ impl Store {
})
}
/// The SHA-256 of every stored file the books name (receipts and
/// documents, withdrawn ones included).
pub fn stored_blob_shas(&self) -> Result<Vec<String>, StoreError> {
let mut stmt = self.conn.prepare("SELECT sha256 FROM blobs")?;
let shas = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(shas.into_iter().map(|s| s.to_lowercase()).collect())
}
/// Verify every blob's bytes against its SHA-256. Returns the count
/// checked. Reads all bytes; run on a schedule, not per request.
pub fn verify_blobs(&self) -> Result<u64, StoreError> {
File diff suppressed because it is too large. Load diff