From a82791789eb0742dd6d8fe97f729f77778c4afb5 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 18 Oct 2026 13:38:50 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 2 + crates/ledger-cli/Cargo.toml | 1 + crates/ledger-cli/src/main.rs | 78 +- crates/ledger-cli/tests/setup.rs | 124 +++ crates/ledger-store/Cargo.toml | 6 + crates/ledger-store/src/lib.rs | 1 + crates/ledger-store/src/root.rs | 1265 +++++++++++++++++++++++++++++ crates/ledger-store/src/store.rs | 28 +- crates/ledger-store/tests/root.rs | 1101 +++++++++++++++++++++++++ docs/testing.md | 27 + plan/served.md | 6 +- 11 files changed, 2629 insertions(+), 10 deletions(-) create mode 100644 crates/ledger-cli/tests/setup.rs create mode 100644 crates/ledger-store/src/root.rs create mode 100644 crates/ledger-store/tests/root.rs diff --git a/Cargo.lock b/Cargo.lock index b2f5fb3..d3534d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3657,6 +3657,7 @@ dependencies = [ "ledger-core", "ledger-gui", "ledger-mcp", + "ledger-store", "serde_json", "tempfile", "toml 1.1.8+spec-1.1.0", @@ -3774,6 +3775,7 @@ dependencies = [ "criterion", "hex", "ledger-core", + "libc", "proptest", "rusqlite", "rust_decimal", diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 421d204..1ffc7f6 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -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] diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 185a615..7d69840 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -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) -> 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) -> i32 { + let mut root: Option = None; + let mut home: Option = 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) -> 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 = 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); } diff --git a/crates/ledger-cli/tests/setup.rs b/crates/ledger-cli/tests/setup.rs new file mode 100644 index 0000000..9ffd136 --- /dev/null +++ b/crates/ledger-cli/tests/setup.rs @@ -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)); +} diff --git a/crates/ledger-store/Cargo.toml b/crates/ledger-store/Cargo.toml index b3ce52a..873c002 100644 --- a/crates/ledger-store/Cargo.toml +++ b/crates/ledger-store/Cargo.toml @@ -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 diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 912562f..8588465 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -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; diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs new file mode 100644 index 0000000..eb05ee8 --- /dev/null +++ b/crates/ledger-store/src/root.rs @@ -0,0 +1,1265 @@ +//! The data root: the one private folder a books server keeps every book +//! in (plan section 3.4). +//! +//! ```text +//! GOAL: the books live hidden away, as the human: a folder only their +//! account can open, outside every project folder, found the same +//! way by the server, the CLI and the viewer. +//! SOURCE: DECISION (owner, 2026-10-16: "you, hidden away"); plan/served.md +//! 3.4 (layout), 3.9 (the host never trusts the opener's +//! environment), 5.5 (adopting existing books). +//! INVARIANTS: +//! 1. The root, every folder made in it, and every missing folder above +//! it that setup makes, is created with mode 0700 set explicitly, +//! never left to the umask; files are 0600. +//! 2. Opening a root refuses one that is a symlink, isn't a folder, +//! belongs to another user, that other users can enter, that setup +//! didn't make (no marker), or that sits under a folder another user +//! could swap it in (one they own, or can write without the sticky +//! bit). +//! 3. Finding the root never reads the environment: an explicit root, +//! else the one `setup` recorded (in a file only this account can +//! write), else the platform's default under the account's home (from +//! the password database on Unix). +//! 4. Setup never moves books: run again, it keeps the root it recorded, +//! and it refuses to make a root elsewhere while the current one holds +//! books; it refuses a folder that holds other things. +//! 5. Adopting books never changes the source's journal, blobs or +//! database (it only takes the source's writer lock, creating the lock +//! file if the source never had one), and a new book appears in the +//! root only whole: every stored file re-hashed against its name, +//! every file the books name present, verified end to end, synced to +//! disk, and its head equal to the source's. +//! 6. No error names the root or a path inside it: nothing an agent is +//! shown names the books' folder. +//! ``` + +use crate::error::StoreError; +use crate::journal::JournalLine; +use crate::store::{JOURNAL_FILE, Store}; +use ledger_core::Currency; +use std::collections::BTreeSet; +use std::path::{Component, Path, PathBuf}; + +/// The root's folder name in the platform's data folder. +pub const ROOT_NAME: &str = "wanderbooks"; + +/// The file `setup` writes beside the default root when the human chose +/// another one; it holds that root's absolute path. +pub const LOCATION_FILE: &str = "wanderbooks.location"; + +/// The file `setup` leaves in a root it made: a root without it isn't +/// opened, and a non-empty folder without it isn't made into one. +pub const MARKER_FILE: &str = ".wanderbooks-root"; + +/// Where books live inside the root. +pub const BOOKS_DIR: &str = "books"; +/// The sockets and runtime state (plan 3.9). +pub const RUN_DIR: &str = "run"; +/// Old copies moved aside at adoption, never deleted (plan 5.5). +pub const LEGACY_DIR: &str = "legacy"; +/// 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"; + +/// Why a root or book operation was refused. No message names the root or +/// a path inside it (INVARIANT 6). +#[derive(Debug, thiserror::Error)] +pub enum RootError { + /// No home folder to put the root under. + #[error("can't find this account's home folder, so there is no default place for the books")] + NoHome, + /// The root, or a folder in or above it, isn't safe to use. + #[error("{0}")] + Unsafe(String), + /// No root yet: `wanderbooks setup` makes one. + #[error("there is no books' private folder yet: run `wanderbooks setup` first")] + Missing, + /// A book id that isn't lowercase letters, digits and hyphens. + #[error( + "{0:?} can't name books: use 1 to 40 lowercase letters, digits and hyphens, starting with a letter or digit" + )] + BadId(String), + /// A book with that id already exists. + #[error("there are already books called {0:?}")] + Exists(String), + /// Adopting was refused, with why. + #[error("{0}")] + Adopt(String), + /// The store refused (its message with the root's path taken out). + #[error("{0}")] + Store(String, #[source] StoreError), + /// Filesystem error (its message with the root's path taken out). + #[error("{0}")] + Io(String, #[source] std::io::Error), +} + +impl RootError { + /// The store's error, if this is one. + pub fn store_error(&self) -> Option<&StoreError> { + match self { + Self::Store(_, e) => Some(e), + _ => None, + } + } +} + +impl From for RootError { + fn from(e: StoreError) -> Self { + Self::Store(e.to_string(), e) + } +} + +impl From for RootError { + fn from(e: std::io::Error) -> Self { + Self::Io(format!("io error: {e}"), e) + } +} + +/// A book's id within the root: its folder name under `books/`. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BookId(String); + +impl BookId { + /// Check an id: 1 to 40 lowercase ASCII letters, digits and hyphens, + /// starting with a letter or digit. + pub fn parse(id: &str) -> Result { + let ok = (1..=40).contains(&id.len()) + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !id.starts_with('-'); + if ok { + Ok(Self(id.to_string())) + } else { + Err(RootError::BadId(id.to_string())) + } + } + + /// The id as text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for BookId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// This account's home folder, from the password database on Unix (never +/// `$HOME`, which the process that started us chose), or the Windows +/// profile. +pub fn account_home() -> Result { + #[cfg(unix)] + { + unix_account() + .map(|(home, _)| home) + .ok_or(RootError::NoHome) + } + #[cfg(windows)] + { + // Read from the environment until the Windows build (P11) takes it + // from the known-folder API. + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .ok_or(RootError::NoHome) + } + #[cfg(not(any(unix, windows)))] + { + Err(RootError::NoHome) + } +} + +/// Call a `get*_r` lookup with a buffer that grows (16 KiB, doubling to +/// at most 1 MiB) while it answers `ERANGE` (too small). `call` gets the +/// buffer and returns the lookup's code with what it read (only on 0). +/// Any other code, or `ERANGE` at the largest size, is `None`. +#[cfg(unix)] +fn with_growing_buffer(mut call: impl FnMut(&mut [u8]) -> (i32, Option)) -> Option { + let mut size = 16 * 1024; + loop { + let mut buf = vec![0u8; size]; + match call(&mut buf) { + (0, found) => return found, + (libc::ERANGE, _) if size < 1024 * 1024 => size *= 2, + _ => return None, + } + } +} + +/// This account's home folder and login name, from the password database. +#[cfg(unix)] +fn unix_account() -> Option<(PathBuf, Vec)> { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + with_growing_buffer(|buf| { + // SAFETY: an all-zero passwd is a valid value of this plain C + // struct (null pointers, zero ids); getpwuid_r overwrites it. + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: getpwuid_r writes into pwd and buf, both alive and sized + // as passed; result is set to &pwd on success or left null. + let rc = unsafe { + libc::getpwuid_r( + libc::getuid(), + &mut pwd, + buf.as_mut_ptr().cast(), + buf.len(), + &mut result, + ) + }; + if rc != 0 || result.is_null() || pwd.pw_dir.is_null() || pwd.pw_name.is_null() { + return (rc, None); + } + // SAFETY: pw_dir and pw_name point into buf, NUL-terminated by + // getpwuid_r, and buf outlives these borrows. + let (dir, name) = unsafe { (CStr::from_ptr(pwd.pw_dir), CStr::from_ptr(pwd.pw_name)) }; + let home = PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes())); + ( + 0, + home.is_absolute().then(|| (home, name.to_bytes().to_vec())), + ) + }) +} + +/// The platform's data folder under `home`: where the default root sits. +pub fn platform_data_dir(home: &Path) -> PathBuf { + if cfg!(target_os = "macos") { + home.join("Library").join("Application Support") + } else if cfg!(windows) { + home.join("AppData").join("Local") + } else { + home.join(".local").join("share") + } +} + +/// The default root under `home`. +pub fn default_root(home: &Path) -> PathBuf { + platform_data_dir(home).join(ROOT_NAME) +} + +/// The data root. +#[derive(Clone, Debug)] +pub struct Root { + path: PathBuf, +} + +/// What [`Root::setup`] did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SetupReport { + /// The root. + pub root: PathBuf, + /// It didn't exist and was made. + pub created: bool, + /// It, or a folder in it, existed with wider permissions, now 0700. + pub tightened: bool, + /// It isn't the default, so its location is recorded. + pub recorded: bool, +} + +impl Root { + /// Find the root for this account: `explicit` (`--root`) if given, + /// else the one `setup` recorded, else the default. The environment + /// is never read (INVARIANT 3). The root must already exist, safely + /// (INVARIANT 2). + pub fn find(explicit: Option<&Path>) -> Result { + Self::find_in(&account_home()?, explicit) + } + + /// [`Self::find`] for the account whose home is `home`. + pub fn find_in(home: &Path, explicit: Option<&Path>) -> Result { + Self::open(&Self::locate(home, explicit)?) + } + + /// Where the root is, by the rule of [`Self::find`], without opening it. + pub fn locate(home: &Path, explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return resolve_new(path); + } + Ok(recorded_root(home)?.unwrap_or_else(|| default_root(home))) + } + + /// Open the root at `path`: it must exist, have been made by setup, + /// and be safe (INVARIANT 2). + pub fn open(path: &Path) -> Result { + let meta = match std::fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RootError::Missing), + Err(e) => return Err(e.into()), + }; + check_private_dir(&meta, false, THE_ROOT)?; + check_ancestors(path)?; + if !path.join(MARKER_FILE).is_file() { + return Err(RootError::Unsafe(format!( + "{THE_ROOT} wasn't made by `wanderbooks setup` (it has no marker); run setup to make one" + ))); + } + Ok(Self { + path: path.to_path_buf(), + }) + } + + /// Make the root (or make an existing one private again) for the + /// account whose home is `home`, and its `books/`, `run/` and + /// `legacy/` folders. Which root: `chosen` (`--root`) if given; else + /// the root already recorded, if any; else + /// `$XDG_DATA_HOME/wanderbooks` when `xdg_data_home` is given and + /// absolute; else the default. A root other than the default is + /// recorded beside the default, so later finds need no environment. + /// Setup never moves books (INVARIANT 4). + pub fn setup( + home: &Path, + chosen: Option<&Path>, + xdg_data_home: Option<&Path>, + ) -> Result<(Self, SetupReport), RootError> { + let default = default_root(home); + let recorded = recorded_root(home)?; + let current = recorded.clone().unwrap_or_else(|| default.clone()); + let path = match (chosen, &recorded, xdg_data_home) { + (Some(path), _, _) => resolve_new(path)?, + (None, Some(recorded), _) => recorded.clone(), + (None, None, Some(xdg)) if xdg.is_absolute() => resolve_new(&xdg.join(ROOT_NAME))?, + _ => default.clone(), + }; + if path != current { + // Only a root that isn't there holds no books: one that can't + // be opened (other users can enter it, say) may well hold some. + let holds_books = match Self::open(¤t) { + Ok(old) => !old.list_books()?.is_empty(), + Err(RootError::Missing) => false, + Err(_) => true, + }; + if holds_books { + let keep = if chosen.is_none() { + "unset XDG_DATA_HOME (or set it back) and run `wanderbooks setup` again to \ + keep using it" + } else { + "run `wanderbooks setup` with no --root to keep using it" + }; + return Err(RootError::Unsafe(format!( + "{THE_ROOT} already exists somewhere else and may hold books: setup won't make \ + a second one or move them ({keep})" + ))); + } + } + // Nothing is changed until every folder above the root that exists + // is known to be safe. + let mut above = path.parent(); + while let Some(dir) = above { + if dir.exists() { + check_dir_and_above(dir)?; + break; + } + above = dir.parent(); + } + let (created, mut tightened) = match std::fs::symlink_metadata(&path) { + Ok(meta) => { + // Checked before anything is changed: a folder that holds + // other things is left exactly as it is. + let open = check_private_dir(&meta, true, "the folder chosen for the books")?; + if !path.join(MARKER_FILE).is_file() && std::fs::read_dir(&path)?.next().is_some() { + return Err(RootError::Unsafe( + "the folder chosen for the books already holds other things: choose an \ + empty folder, or one `wanderbooks setup` made" + .into(), + )); + } + (false, tighten_if(&path, open)?) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = path.parent() { + make_private_dirs(parent)?; + } + make_private_dir(&path)?; + (true, false) + } + Err(e) => return Err(e.into()), + }; + check_ancestors(&path)?; + if !path.join(MARKER_FILE).is_file() { + write_private_file(&path.join(MARKER_FILE), b"wanderbooks data root\n")?; + } + let root = Self { path: path.clone() }; + for sub in [BOOKS_DIR, RUN_DIR, LEGACY_DIR] { + tightened |= root.private_subdir(sub, true)?.1; + } + sync_dir(&path)?; + let pointer = platform_data_dir(home).join(LOCATION_FILE); + let record = path != default; + if record { + make_private_dirs(&platform_data_dir(home))?; + let tmp = pointer.with_extension("location.tmp"); + let _ = std::fs::remove_file(&tmp); + write_private_file(&tmp, format!("{}\n", path.display()).as_bytes())?; + std::fs::rename(&tmp, &pointer)?; + sync_dir(&platform_data_dir(home))?; + } else { + match std::fs::remove_file(&pointer) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(( + root, + SetupReport { + root: path, + created, + tightened, + recorded: record, + }, + )) + } + + /// The root's path. + pub fn path(&self) -> &Path { + &self.path + } + + /// The folder a book lives in. + pub fn book_dir(&self, id: &BookId) -> PathBuf { + self.path.join(BOOKS_DIR).join(id.as_str()) + } + + /// A folder directly in the root: made 0700 if missing; refused if it + /// is a symlink, belongs to another user, or (unless `tighten`) others + /// can enter it. Returns it and whether it was tightened. + fn private_subdir(&self, name: &str, tighten: bool) -> Result<(PathBuf, bool), RootError> { + let dir = self.path.join(name); + let tightened = match std::fs::symlink_metadata(&dir) { + Ok(meta) => tighten_if( + &dir, + check_private_dir(&meta, tighten, "a folder inside it")?, + )?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + make_private_dir(&dir)?; + false + } + Err(e) => return Err(e.into()), + }; + Ok((dir, tightened)) + } + + /// Hide the root's path in an error (INVARIANT 6). + fn hide(&self, e: RootError) -> RootError { + let root = self.path.display().to_string(); + let clean = |text: String| text.replace(&root, THE_ROOT); + match e { + RootError::Store(text, e) => RootError::Store(clean(text), e), + RootError::Io(text, e) => RootError::Io(clean(text), e), + RootError::Adopt(text) => RootError::Adopt(clean(text)), + RootError::Unsafe(text) => RootError::Unsafe(clean(text)), + other => other, + } + } + + /// The books in the root, by id: each folder under `books/` with a + /// valid id and a journal. Staging folders and anything else are left + /// out. + pub fn list_books(&self) -> Result, RootError> { + self.list_books_inner().map_err(|e| self.hide(e)) + } + + fn list_books_inner(&self) -> Result, RootError> { + let (books, _) = self.private_subdir(BOOKS_DIR, false)?; + let mut ids = Vec::new(); + for entry in std::fs::read_dir(books)? { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let Ok(id) = BookId::parse(&name) else { + continue; + }; + if entry.file_type()?.is_dir() && entry.path().join(JOURNAL_FILE).is_file() { + ids.push(id); + } + } + ids.sort(); + Ok(ids) + } + + /// Take the lock under which books are made: one at a time, across + /// processes. + fn books_lock(&self) -> Result { + let (run, _) = self.private_subdir(RUN_DIR, false)?; + let path = run.join(BOOKS_LOCK); + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path)?; + set_mode(&path, 0o600)?; + file.lock()?; + Ok(file) + } + + /// Start new, empty books called `id` in `base_currency`. Refused if + /// books of that id exist. Returns the open store. + pub fn create_book(&self, id: &BookId, base_currency: Currency) -> Result { + self.create_book_inner(id, base_currency) + .map_err(|e| self.hide(e)) + } + + fn create_book_inner(&self, id: &BookId, base_currency: Currency) -> Result { + let _lock = self.books_lock()?; + let (books, _) = self.private_subdir(BOOKS_DIR, false)?; + let dir = books.join(id.as_str()); + if std::fs::symlink_metadata(&dir).is_ok() { + return Err(RootError::Exists(id.to_string())); + } + make_private_dir(&dir)?; + match Store::create(&dir, base_currency) { + Ok(store) => { + sync_dir(&dir)?; + sync_dir(&books)?; + Ok(store) + } + Err(e) => { + // Only the folder this call just made, still without a + // journal, is removed. + if !dir.join(JOURNAL_FILE).exists() { + let _ = std::fs::remove_dir_all(&dir); + } + Err(e.into()) + } + } + } + + /// Adopt existing books from `source` (a data dir: `journal.jsonl`, + /// `blobs/`) as books called `id` (plan 5.5, steps 1 to 5). The source + /// is only read, under its writer lock (INVARIANT 5); the copy is + /// built in a staging folder: every stored file re-hashed against its + /// name, the copy opened (its database rebuilt from the journal) with + /// the source's head, every file it names present, verified end to + /// end, given its settings if the source keeps none (and verified + /// again), synced, and only then renamed into place. + pub fn adopt( + &self, + source: &Path, + id: &BookId, + options: &AdoptOptions, + ) -> Result { + self.adopt_inner(source, id, options) + .map_err(|e| self.hide(e)) + } + + fn adopt_inner( + &self, + source: &Path, + id: &BookId, + options: &AdoptOptions, + ) -> Result { + let _lock = self.books_lock()?; + let (books, _) = self.private_subdir(BOOKS_DIR, false)?; + let dest = books.join(id.as_str()); + 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() + ))); + } + for (what, path) in [ + ("vault folder", &options.vault_dir), + ("vault key", &options.vault_key), + ] { + if let Some(path) = path + && !path.exists() + { + return Err(RootError::Adopt(format!( + "the {what} given, {}, isn't there: nothing was adopted", + path.display() + ))); + } + } + // Step 1: the source's writer lock, held to the end, so nothing + // 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 head = head_of(&lines); + let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set"); + if !keeps_settings && options.settings.is_none() { + return Err(RootError::Adopt( + "these books keep no settings of their own yet: give their settings file \ + (`--toml`), and its settings are moved in as they are adopted" + .into(), + )); + } + // Staging: anything left from an adoption that never finished goes + // first. None is in progress: every adoption holds the books lock. + for entry in std::fs::read_dir(&books)? { + let entry = entry?; + if entry + .file_name() + .to_str() + .is_some_and(|n| n.starts_with(STAGING_PREFIX)) + { + std::fs::remove_dir_all(entry.path())?; + } + } + let staging = books.join(format!("{STAGING_PREFIX}{id}-{}", std::process::id())); + make_private_dir(&staging)?; + let built = (|| -> Result { + // Step 3: the journal as read, then every stored file. + write_private_file(&staging.join(JOURNAL_FILE), &raw)?; + let blob_dir = crate::store::BLOBS_DIR; + 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"))?, + None => 0, + }; + let key_copied = match &options.vault_key { + Some(key) => { + let keys = staging.join("keys"); + make_private_dir(&keys)?; + write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?; + sync_dir(&keys)?; + true + } + None => false, + }; + let mut store = Store::open(&staging)?; + if store.head() != head { + return Err(RootError::Adopt(format!( + "the copy's head {:?} isn't the source's {:?}; nothing was adopted", + store.head(), + head + ))); + } + let named: BTreeSet = store.stored_blob_shas()?.into_iter().collect(); + let missing: Vec<&String> = named.difference(&copied.shas).collect(); + if !missing.is_empty() { + return Err(RootError::Adopt(format!( + "the books name stored files that aren't there ({}): nothing was adopted", + missing + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ))); + } + store.verify_and_heal()?; + let blobs_verified = store.verify_blobs()?; + let (settings_imported, settings_ignored) = match (&options.settings, keeps_settings) { + (Some((changes, source)), false) => { + let n = store.adopt_settings(changes, source)?.len(); + store.verify_and_heal()?; + (n, false) + } + (Some(_), true) => (0, true), + (None, _) => (0, false), + }; + let final_head = store.head(); + drop(store); + sync_dir(&staging)?; + Ok(AdoptReport { + id: id.clone(), + source_head: head.clone(), + head: final_head, + blob_files: copied.shas.len() as u64, + blobs_verified, + unnamed_blobs: copied.shas.difference(&named).count() as u64, + skipped_files: copied.skipped, + vault_files, + key_copied, + settings_imported, + settings_ignored, + source_lock_created, + }) + })(); + let renamed = built.and_then(|report| { + std::fs::rename(&staging, &dest)?; + sync_dir(&books)?; + Ok(report) + }); + if renamed.is_err() { + let _ = std::fs::remove_dir_all(&staging); + } + renamed + } +} + +/// Options for [`Root::adopt`]. +#[derive(Clone, Debug, Default)] +pub struct AdoptOptions { + /// Settings to move in, parsed from the books' settings file, for + /// books that keep none of their own yet (journaled as their first + /// `settings.set`). For books that keep their own they are ignored, + /// and the report says so. + pub settings: Option<( + Vec, + ledger_core::settings::Source, + )>, + /// The source's TIN vault folder: its records are copied into the + /// book's `vault/`. Given, it must exist. + pub vault_dir: Option, + /// The v1 vault key file: copied to the book's `keys/legacy.key`. + /// Given, it must exist. + pub vault_key: Option, +} + +/// What [`Root::adopt`] did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AdoptReport { + /// The new book's id. + pub id: BookId, + /// The source's head, `(seq, hash)`, verified before copying. + pub source_head: (u64, String), + /// The adopted books' head: the source's, plus (when settings were + /// moved in) their `settings.set`, after the journal format they need + /// if the source's is older. + pub head: (u64, String), + /// Stored files copied, each re-hashed against its name. + pub blob_files: u64, + /// Stored files the books name, re-hashed again through the store. + pub blobs_verified: u64, + /// Stored files copied that the books don't name (kept: a stored file + /// is never thrown away). + pub unnamed_blobs: u64, + /// Files left behind among the stored ones: unfinished writes + /// (`.tmp`, a put that never finished) and hidden files + /// (`.DS_Store` and the like, which no wanderbooks writes). + pub skipped_files: u64, + /// Vault records copied. + pub vault_files: u64, + /// Whether the v1 key was copied. + pub key_copied: bool, + /// Settings journaled on the way in. + pub settings_imported: usize, + /// Settings were given but the books keep their own, so they weren't + /// used. + pub settings_ignored: bool, + /// The source had no lock file, so taking its lock made one. + pub source_lock_created: bool, +} + +/// The source's writer lock: its lock file opened read-only (so a +/// read-only source can be adopted and nothing in it changes), or, for +/// books that never had one, created. Returns the lock and whether the +/// file was made. +fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> { + let path = source.join(crate::store::LOCK_FILE); + let (file, created) = match std::fs::File::open(&path) { + Ok(file) => (file, false), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + (crate::store::lock_books_for_adoption(source)?, true) + } + Err(e) => return Err(e.into()), + }; + if !created { + 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() + )))); + } + Err(std::fs::TryLockError::Error(e)) => return Err(e.into()), + } + } + Ok((file, created)) +} + +/// The head of a verified journal: the last line's seq and the hash the +/// next line would chain from. +fn head_of(lines: &[JournalLine]) -> (u64, String) { + use sha2::{Digest, Sha256}; + match lines.last() { + Some(last) => ( + last.seq, + hex::encode(Sha256::digest(last.to_canonical().as_bytes())), + ), + None => (0, crate::journal::GENESIS_PREV.to_string()), + } +} + +/// The root setup recorded, if it did: the location file must be a plain +/// file of this account's that no one else can write (INVARIANT 3), naming +/// an absolute folder. +fn recorded_root(home: &Path) -> Result, RootError> { + let pointer = platform_data_dir(home).join(LOCATION_FILE); + let meta = match std::fs::symlink_metadata(&pointer) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let unsafe_pointer = || { + RootError::Unsafe( + "the note of where the books' private folder is (wanderbooks.location) isn't this \ + account's own private file: remove it and run `wanderbooks setup` again" + .into(), + ) + }; + if !meta.file_type().is_file() { + return Err(unsafe_pointer()); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // SAFETY: getuid has no preconditions and never fails. + if meta.uid() != unsafe { libc::getuid() } || meta.permissions().mode() & 0o022 != 0 { + return Err(unsafe_pointer()); + } + } + let text = std::fs::read_to_string(&pointer)?; + let path = PathBuf::from(text.trim_end_matches(['\n', '\r'])); + if path.is_absolute() { + Ok(Some(path)) + } else { + Err(RootError::Unsafe( + "the note of where the books' private folder is (wanderbooks.location) names a \ + relative folder: run `wanderbooks setup` again" + .into(), + )) + } +} + +/// A folder that may not exist yet, made absolute and named plainly: no +/// `..` anywhere (refused), the folders above it that exist resolved +/// fully (links and all), and its own last name kept as given, so a root +/// that is a link is still seen as one and refused. +fn resolve_new(path: &Path) -> Result { + let path = std::path::absolute(path)?; + if path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(RootError::Unsafe( + "the folder chosen for the books has `..` in it: name it plainly".into(), + )); + } + let (Some(name), Some(parent)) = (path.file_name(), path.parent()) else { + return Err(RootError::Unsafe( + "the folder chosen for the books can't be the top of the file system".into(), + )); + }; + let mut existing = parent; + let mut rest = Vec::new(); + while !existing.exists() { + match (existing.file_name(), existing.parent()) { + (Some(n), Some(p)) => { + rest.push(n.to_os_string()); + existing = p; + } + _ => break, + } + } + let mut out = std::fs::canonicalize(existing)?; + for n in rest.into_iter().rev() { + out.push(n); + } + out.push(name); + Ok(out) +} + +/// Make one folder, mode 0700 set explicitly (INVARIANT 1). +fn make_private_dir(path: &Path) -> Result<(), RootError> { + std::fs::create_dir(path)?; + set_mode(path, 0o700)?; + Ok(()) +} + +/// Make a folder and any missing above it, each 0700 (INVARIANT 1). +fn make_private_dirs(path: &Path) -> Result<(), RootError> { + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + make_private_dirs(parent)?; + } + match make_private_dir(path) { + Err(RootError::Io(_, e)) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + other => other, + } +} + +/// Write a new file, mode 0600 set explicitly, synced. +fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), RootError> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + set_mode(path, 0o600)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) +} + +/// Make a folder's entries durable (Unix; NTFS journals its own). +fn sync_dir(path: &Path) -> Result<(), RootError> { + #[cfg(unix)] + std::fs::File::open(path)?.sync_all()?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +#[cfg(unix)] +fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) +} + +#[cfg(not(unix))] +fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> { + // Windows: the owner-only DACL comes with the Windows build (P11). + Ok(()) +} + +/// Check an existing folder is private (INVARIANT 2). With `tighten`, a +/// folder of ours that others can enter is reported (true) for the caller +/// to make 0700 with [`tighten_if`]; without, it is refused. `what` names +/// it in messages. +fn check_private_dir( + meta: &std::fs::Metadata, + tighten: bool, + what: &str, +) -> Result { + if meta.file_type().is_symlink() { + return Err(RootError::Unsafe(format!( + "{what} is a symlink: it must be a real folder" + ))); + } + if !meta.is_dir() { + return Err(RootError::Unsafe(format!("{what} isn't a folder"))); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // SAFETY: getuid has no preconditions and never fails. + let uid = unsafe { libc::getuid() }; + if meta.uid() != uid { + return Err(RootError::Unsafe(format!( + "{what} belongs to another user: it must be this account's own" + ))); + } + if meta.permissions().mode() & 0o077 != 0 { + if !tighten { + return Err(RootError::Unsafe(format!( + "other users can open {what}: run `wanderbooks setup` to make it private" + ))); + } + return Ok(true); + } + } + #[cfg(not(unix))] + let _ = tighten; + Ok(false) +} + +/// Every folder above `path` must be one another user can't swap it in +/// (INVARIANT 2): see [`check_dir_and_above`]. +fn check_ancestors(path: &Path) -> Result<(), RootError> { + match path.parent() { + Some(parent) => check_dir_and_above(parent), + None => Ok(()), + } +} + +/// `dir` and every folder above it: owned by this account or by root, +/// and writable by no other user. Others' write is allowed only with the +/// sticky bit (like `/tmp`); group write only when the group is this +/// account's own private group (its primary group, with no other members: +/// the usual umask-002 setup on many Linux systems). +fn check_dir_and_above(dir: &Path) -> Result<(), RootError> { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // SAFETY: getuid has no preconditions and never fails. + let uid = unsafe { libc::getuid() }; + for d in dir.ancestors() { + let meta = std::fs::metadata(d)?; + let mode = meta.permissions().mode(); + let sticky = mode & 0o1000 != 0; + let owner_ok = meta.uid() == uid || meta.uid() == 0; + let others_ok = mode & 0o002 == 0 || sticky; + let group_ok = mode & 0o020 == 0 || sticky || is_private_group(meta.gid()); + if !(owner_ok && others_ok && group_ok) { + return Err(RootError::Unsafe(format!( + "a folder above {THE_ROOT} can be changed by another user: put the books \ + somewhere only this account (or the system) controls" + ))); + } + } + } + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +/// Whether `gid` is this account's own private group (the user-private +/// group convention): see [`private_group_rule`]. +#[cfg(unix)] +fn is_private_group(gid: u32) -> bool { + // SAFETY: getgid has no preconditions and never fails. + let primary = unsafe { libc::getgid() }; + let Some((_, login)) = unix_account() else { + return false; + }; + let group = with_growing_buffer(|buf| { + // SAFETY: an all-zero group is a valid value of this plain C + // struct; getgrgid_r overwrites it. + let mut grp: libc::group = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::group = std::ptr::null_mut(); + // SAFETY: getgrgid_r writes into grp and buf, both alive and sized + // as passed; result is set to &grp on success or left null. + let rc = unsafe { + libc::getgrgid_r( + gid, + &mut grp, + buf.as_mut_ptr().cast(), + buf.len(), + &mut result, + ) + }; + if rc != 0 || result.is_null() || grp.gr_name.is_null() { + return (rc, None); + } + // SAFETY: gr_name points into buf, NUL-terminated by getgrgid_r; + // gr_mem is a null-terminated array of pointers into buf, and only + // its first element is read. + let name = unsafe { std::ffi::CStr::from_ptr(grp.gr_name) } + .to_bytes() + .to_vec(); + let members = !grp.gr_mem.is_null() && !unsafe { *grp.gr_mem }.is_null(); + (0, Some((name, members))) + }); + group.is_some_and(|(name, members)| private_group_rule(gid, primary, &name, &login, members)) +} + +/// The user-private group convention: a group is this account's own when +/// it is the account's primary group, is named as the account's login is, +/// and lists no members. A shared primary group (openSUSE's `users`) has +/// another name, so it isn't. +fn private_group_rule(gid: u32, primary: u32, name: &[u8], login: &[u8], members: bool) -> bool { + gid == primary && name == login && !members +} + +/// Tighten `path` to 0700 when setup found it open (see +/// [`check_private_dir`]); applied by the caller that asked to tighten. +fn tighten_if(path: &Path, tightened: bool) -> Result { + if tightened { + set_mode(path, 0o700)?; + } + Ok(tightened) +} + +/// What [`copy_blobs`] copied. +struct CopiedBlobs { + /// Each stored file's SHA-256, re-hashed from its bytes. + shas: BTreeSet, + /// Unfinished writes (`.tmp`) and hidden files (`.DS_Store` and the + /// like) left behind. + skipped: u64, +} + +/// Copy the blob store: each file must sit at `aa/bb/` and hash to +/// its name; an unfinished write (`.tmp`, a put that never finished) +/// is left behind and counted; anything else (a link, a stray file) is +/// refused. Folders 0700, files 0600, every folder synced. +fn copy_blobs(from: &Path, to: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut copied = CopiedBlobs { + shas: BTreeSet::new(), + skipped: 0, + }; + if !from.exists() { + return Ok(copied); + } + make_private_dir(to)?; + let stray = |p: &Path| { + RootError::Adopt(format!( + "{} isn't a stored file where the books keep them: nothing was adopted", + p.display() + )) + }; + let is_hex = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_hexdigit()); + // A hidden plain file (`.DS_Store`): no wanderbooks writes one; left + // behind and counted. + let hidden = |name: &str, kind: std::fs::FileType| name.starts_with('.') && kind.is_file(); + for a in std::fs::read_dir(from)? { + let a = a?; + let a_name = a.file_name().to_string_lossy().into_owned(); + if hidden(&a_name, a.file_type()?) { + copied.skipped += 1; + continue; + } + if !a.file_type()?.is_dir() || !is_hex(&a_name, 2) { + return Err(stray(&a.path())); + } + let a_to = to.join(&a_name); + make_private_dir(&a_to)?; + for b in std::fs::read_dir(a.path())? { + let b = b?; + let b_name = b.file_name().to_string_lossy().into_owned(); + if hidden(&b_name, b.file_type()?) { + copied.skipped += 1; + continue; + } + if !b.file_type()?.is_dir() || !is_hex(&b_name, 2) { + return Err(stray(&b.path())); + } + let b_to = a_to.join(&b_name); + make_private_dir(&b_to)?; + for f in std::fs::read_dir(b.path())? { + let f = f?; + let name = f.file_name().to_string_lossy().into_owned(); + let kind = f.file_type()?; + if hidden(&name, kind) + || (kind.is_file() + && name.strip_suffix(".tmp").is_some_and(|sha| is_hex(sha, 64))) + { + copied.skipped += 1; + continue; + } + let placed = kind.is_file() + && is_hex(&name, 64) + && name.starts_with(&a_name) + && name[2..].starts_with(&b_name); + if !placed { + if kind.is_symlink() { + return Err(RootError::Adopt(format!( + "{} is a link: the books' files are never links", + f.path().display() + ))); + } + return Err(stray(&f.path())); + } + let bytes = std::fs::read(f.path())?; + if hex::encode(Sha256::digest(&bytes)) != name.to_lowercase() { + return Err(RootError::Adopt(format!( + "the stored file {name} doesn't match its fingerprint (its bytes changed): \ + nothing was adopted" + ))); + } + write_private_file(&b_to.join(&name), &bytes)?; + copied.shas.insert(name.to_lowercase()); + } + sync_dir(&b_to)?; + } + sync_dir(&a_to)?; + } + sync_dir(to)?; + Ok(copied) +} + +/// Copy the regular files directly in `from` (the vault's records) into a +/// new private folder `to`; folders and links in it are refused. +fn copy_flat_private(from: &Path, to: &Path) -> Result { + make_private_dir(to)?; + let mut n = 0; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + return Err(RootError::Adopt(format!( + "{} isn't a plain file: the vault holds only its records", + entry.path().display() + ))); + } + write_private_file(&to.join(entry.file_name()), &std::fs::read(entry.path())?)?; + n += 1; + } + sync_dir(to)?; + Ok(n) +} + +#[cfg(all(test, unix))] +mod unit { + use super::*; + + #[test] + fn a_buffer_grows_while_the_lookup_says_too_small() { + // Too small twice, then found: 16, 32, 64 KiB tried, in order. + let mut sizes = Vec::new(); + let found = with_growing_buffer(|buf| { + sizes.push(buf.len()); + if sizes.len() < 3 { + (libc::ERANGE, None) + } else { + (0, Some(7)) + } + }); + assert_eq!(found, Some(7)); + assert_eq!(sizes, vec![16 * 1024, 32 * 1024, 64 * 1024]); + // Too small at every size: it stops at 1 MiB with nothing. + let mut sizes = Vec::new(); + let found: Option = with_growing_buffer(|buf| { + sizes.push(buf.len()); + (libc::ERANGE, Some(1)) + }); + assert_eq!(found, None); + assert_eq!(sizes.last(), Some(&(1024 * 1024))); + assert_eq!(sizes.len(), 7); + // Any other failure: nothing, at once. + let mut calls = 0; + let found: Option = with_growing_buffer(|_| { + calls += 1; + (libc::ENOENT, Some(1)) + }); + assert_eq!((found, calls), (None, 1)); + // Found with nothing in it (no such entry): nothing. + assert_eq!(with_growing_buffer(|_| (0, None::)), None); + } + + #[test] + fn a_private_group_is_the_account_s_own_name_with_no_members() { + assert!(private_group_rule(1000, 1000, b"pat", b"pat", false)); + assert!( + !private_group_rule(1001, 1000, b"pat", b"pat", false), + "not primary" + ); + assert!( + !private_group_rule(100, 100, b"users", b"pat", false), + "shared" + ); + assert!( + !private_group_rule(1000, 1000, b"pat", b"pat", true), + "has members" + ); + } + + #[test] + fn root_s_group_is_not_this_account_s_own() { + // SAFETY: getgid has no preconditions. + if unsafe { libc::getgid() } != 0 { + assert!(!is_private_group(0)); + } + } + + #[test] + fn the_account_s_home_is_an_absolute_folder() { + let home = account_home().unwrap(); + assert!(home.is_absolute() && home.is_dir(), "{}", home.display()); + let (_, login) = unix_account().unwrap(); + assert!(!login.is_empty()); + } +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 6006079..5fa8495 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -88,6 +88,12 @@ fn lock_books(data_dir: &Path) -> Result { 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 { + 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, StoreError> { + let mut stmt = self.conn.prepare("SELECT sha256 FROM blobs")?; + let shas = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + 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 { diff --git a/crates/ledger-store/tests/root.rs b/crates/ledger-store/tests/root.rs new file mode 100644 index 0000000..28e086d --- /dev/null +++ b/crates/ledger-store/tests/root.rs @@ -0,0 +1,1101 @@ +//! The data root (root.rs): one private folder for every book, found +//! without the environment, and books adopted into it whole. +#![cfg(unix)] + +use chrono::NaiveDate; +use ledger_core::settings::{Change, Source}; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::root::{ + AdoptOptions, BookId, LOCATION_FILE, MARKER_FILE, Root, RootError, default_root, + platform_data_dir, +}; +use ledger_store::{JOURNAL_FILE, JournalLine, Snapshot, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::json; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +fn tmp(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("wb-root-{tag}-")) + .tempdir() + .unwrap() +} + +fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +fn src(by: &str) -> Source { + Source { + decided_by: by.into(), + decided_on: NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(), + citation: None, + note: None, + } +} + +fn set(key: &str, value: serde_json::Value) -> Change { + Change { + key: key.into(), + value: Some(value), + } +} + +/// Books with accounts, a posted entry with its receipt, a filed document, +/// and (with `settings`) settings of their own: what adoption must carry. +fn source_books(dir: &Path, settings: bool) -> Store { + let mut s = Store::create(dir, Currency::USD).unwrap(); + if settings { + s.adopt_settings(&[set("company.name", json!("Adopted LLC"))], &src("Owner")) + .unwrap(); + } + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let fees = s + .add_account("6000", "Fees", AccountType::Expense, None, false) + .unwrap(); + let e = s + .create_entry(NaiveDate::from_ymd_opt(2026, 10, 9).unwrap(), "Filing fee") + .unwrap(); + let m = Money::new(Decimal::new(5900, 2), Currency::USD); + s.add_line(e, fees, Side::Debit, m).unwrap(); + s.add_line(e, bank, Side::Credit, m).unwrap(); + s.attach_receipt( + Some(e), + "fee.pdf", + "application/pdf", + b"%PDF fee receipt", + None, + ) + .unwrap(); + s.post_entry(e).unwrap(); + s.file_document( + "formation", + "cert.pdf", + "application/pdf", + b"%PDF certificate", + None, + ) + .unwrap(); + s +} + +/// Every file under `dir`, with its bytes, for before/after comparison. +fn snapshot_files(dir: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for e in std::fs::read_dir(&d).unwrap() { + let e = e.unwrap(); + let p = e.path(); + if e.file_type().unwrap().is_dir() { + stack.push(p); + } else { + out.push((p.clone(), std::fs::read(&p).unwrap())); + } + } + } + out.sort(); + out +} + +/// The error of a call that must fail (a Store isn't Debug). +fn refused(r: Result) -> RootError { + match r { + Ok(_) => panic!("expected a refusal"), + Err(e) => e, + } +} + +fn id(s: &str) -> BookId { + BookId::parse(s).unwrap() +} + +#[test] +fn book_ids_are_plain_lowercase_names() { + for ok in ["a", "wanderland", "acme-2026", "0", &"x".repeat(40)] { + assert!(BookId::parse(ok).is_ok(), "{ok:?}"); + } + for bad in [ + "", + "-a", + "A", + "acme_llc", + "acme llc", + "acme.", + "..", + "a/b", + "é", + &"x".repeat(41), + ] { + assert!( + matches!(BookId::parse(bad), Err(RootError::BadId(_))), + "{bad:?}" + ); + } +} + +#[test] +fn the_root_is_found_without_the_environment() { + let home = tmp("find"); + // Nothing recorded: the platform default under the home. + assert_eq!( + Root::locate(home.path(), None).unwrap(), + default_root(home.path()) + ); + assert_eq!( + default_root(home.path()), + home.path().join(".local/share/wanderbooks") + ); + // An explicit root wins. + let elsewhere = home.path().join("elsewhere"); + assert_eq!( + Root::locate(home.path(), Some(&elsewhere)).unwrap(), + elsewhere + ); + // A recorded root is used when none is given. + std::fs::create_dir_all(platform_data_dir(home.path())).unwrap(); + std::fs::write( + platform_data_dir(home.path()).join(LOCATION_FILE), + format!("{}\n", elsewhere.display()), + ) + .unwrap(); + assert_eq!(Root::locate(home.path(), None).unwrap(), elsewhere); + // A relative record is refused, not guessed at. + std::fs::write( + platform_data_dir(home.path()).join(LOCATION_FILE), + "relative/root\n", + ) + .unwrap(); + assert!(matches!( + Root::locate(home.path(), None), + Err(RootError::Unsafe(_)) + )); +} + +#[test] +fn setup_makes_a_private_root_and_its_folders() { + let home = tmp("setup"); + let (root, report) = Root::setup(home.path(), None, None).unwrap(); + assert_eq!(root.path(), default_root(home.path())); + assert!(report.created && !report.tightened && !report.recorded); + assert_eq!(mode(root.path()), 0o700); + for sub in ["books", "run", "legacy"] { + assert_eq!(mode(&root.path().join(sub)), 0o700, "{sub}"); + } + // Again: nothing to do, nothing recorded. + let (_, again) = Root::setup(home.path(), None, None).unwrap(); + assert!(!again.created && !again.tightened && !again.recorded); + assert!(Root::find_in(home.path(), None).is_ok()); +} + +#[test] +fn setup_records_a_root_chosen_elsewhere_and_forgets_it_when_back_to_default() { + let home = tmp("setup-elsewhere"); + let chosen = home.path().join("my books"); + let (_, report) = Root::setup(home.path(), Some(&chosen), None).unwrap(); + assert!(report.recorded); + let pointer = platform_data_dir(home.path()).join(LOCATION_FILE); + assert_eq!(mode(&pointer), 0o600); + assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen); + // Back to the default (it holds no books): the record goes. + let (root, report) = Root::setup(home.path(), Some(&default_root(home.path())), None).unwrap(); + assert_eq!(root.path(), default_root(home.path())); + assert!(!report.recorded && !pointer.exists()); + + // $XDG_DATA_HOME is honoured at setup, when nothing is recorded, and + // recorded. + let fresh = tmp("setup-xdg"); + let xdg = fresh.path().join("xdg"); + let (root, report) = Root::setup(fresh.path(), None, Some(&xdg)).unwrap(); + assert_eq!(root.path(), xdg.join("wanderbooks")); + assert!(report.recorded); + assert_eq!( + Root::find_in(fresh.path(), None).unwrap().path(), + xdg.join("wanderbooks") + ); + // A relative XDG_DATA_HOME is ignored, as the spec says. + let other = tmp("setup-rel"); + let (root, report) = Root::setup(other.path(), None, Some(Path::new("rel"))).unwrap(); + assert_eq!(root.path(), default_root(other.path())); + assert!(!report.recorded); +} + +#[test] +fn setup_tightens_a_root_others_can_enter_and_open_refuses_one() { + let home = tmp("tighten"); + let root = default_root(home.path()); + std::fs::create_dir_all(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(matches!(Root::open(&root), Err(RootError::Unsafe(_)))); + let (_, report) = Root::setup(home.path(), None, None).unwrap(); + assert!(report.tightened && !report.created); + assert_eq!(mode(&root), 0o700); + // A folder inside that others can enter is refused too. + std::fs::set_permissions(root.join("books"), std::fs::Permissions::from_mode(0o750)).unwrap(); + let opened = Root::open(&root).unwrap(); + assert!(matches!(opened.list_books(), Err(RootError::Unsafe(_)))); + // Setup makes it private again. + let (_, report) = Root::setup(home.path(), None, None).unwrap(); + assert!(report.tightened); + assert_eq!(mode(&root.join("books")), 0o700); + assert!(opened.list_books().unwrap().is_empty()); +} + +#[test] +fn a_root_that_is_a_link_or_a_file_or_missing_is_refused() { + let home = tmp("unsafe"); + let real = home.path().join("real"); + std::fs::create_dir(&real).unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o700)).unwrap(); + let link = home.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + assert!(matches!(Root::open(&link), Err(RootError::Unsafe(_)))); + assert!(matches!( + Root::setup(home.path(), Some(&link), None), + Err(RootError::Unsafe(_)) + )); + let file = home.path().join("file"); + std::fs::write(&file, "x").unwrap(); + assert!(matches!(Root::open(&file), Err(RootError::Unsafe(_)))); + assert!(matches!( + Root::open(&home.path().join("none")), + Err(RootError::Missing) + )); + // A private folder setup didn't make (no marker) isn't a root. + assert!(matches!(Root::open(&real), Err(RootError::Unsafe(why)) if why.contains("no marker"))); +} + +#[test] +fn books_are_created_listed_and_never_made_twice() { + let home = tmp("create"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + assert!(root.list_books().unwrap().is_empty()); + let store = root.create_book(&id("acme"), Currency::USD).unwrap(); + assert_eq!(store.data_dir(), root.book_dir(&id("acme"))); + drop(store); + assert_eq!(mode(&root.book_dir(&id("acme"))), 0o700); + assert!(matches!( + root.create_book(&id("acme"), Currency::USD), + Err(RootError::Exists(_)) + )); + root.create_book(&id("beta"), Currency::USD).unwrap(); + // Folders that aren't books, or aren't named as books are, are not listed. + std::fs::create_dir(root.path().join("books/.adopt-x-1")).unwrap(); + std::fs::create_dir(root.path().join("books/empty")).unwrap(); + std::fs::create_dir(root.path().join("books/Bad_Name")).unwrap(); + std::fs::write(root.path().join("books/Bad_Name").join(JOURNAL_FILE), "").unwrap(); + assert_eq!(root.list_books().unwrap(), vec![id("acme"), id("beta")]); +} + +#[test] +fn adopted_books_are_the_same_books_and_the_source_is_untouched() { + let home = tmp("adopt"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-src"); + let data = old.path().join("books-data"); + let source = source_books(&data, true); + let source_head = source.head(); + let source_ledger = source.ledger().clone(); + drop(source); + // A vault and a key beside them, as the old layout kept them. + let vault = old.path().join("vault"); + std::fs::create_dir(&vault).unwrap(); + std::fs::write(vault.join("tin-7.bin"), b"sealed record").unwrap(); + let key = old.path().join("vault.key"); + std::fs::write(&key, b"0123456789abcdef0123456789abcdef").unwrap(); + let before = snapshot_files(old.path()); + + let report = root + .adopt( + &data, + &id("acme"), + &AdoptOptions { + settings: None, + vault_dir: Some(vault.clone()), + vault_key: Some(key.clone()), + }, + ) + .unwrap(); + assert_eq!(report.source_head, source_head); + assert_eq!( + report.head, source_head, + "books with settings gain no event" + ); + assert_eq!(report.blob_files, 2); + assert_eq!(report.blobs_verified, 2); + assert_eq!( + ( + report.vault_files, + report.key_copied, + report.settings_imported + ), + (1, true, 0) + ); + + // The same books: every reader agrees with the source's ledger. + let dir = root.book_dir(&id("acme")); + let adopted = Store::open(&dir).unwrap(); + adopted.verify().unwrap(); + assert!(adopted.ledger().differs_from(&source_ledger).is_empty()); + drop(adopted); + assert!( + Snapshot::load(&dir) + .unwrap() + .ledger() + .differs_from(&source_ledger) + .is_empty() + ); + // Private, inside and out. + assert_eq!(mode(&dir), 0o700); + assert_eq!(mode(&dir.join("journal.jsonl")), 0o600); + assert_eq!(mode(&dir.join("vault")), 0o700); + assert_eq!(mode(&dir.join("vault/tin-7.bin")), 0o600); + assert_eq!(mode(&dir.join("keys/legacy.key")), 0o600); + assert_eq!( + std::fs::read(dir.join("keys/legacy.key")).unwrap(), + std::fs::read(&key).unwrap() + ); + // The source: byte for byte as it was, nothing added or gone. + assert_eq!(snapshot_files(old.path()), before); + // No staging left behind; the book is listed. + assert_eq!(root.list_books().unwrap(), vec![id("acme")]); + assert_eq!( + std::fs::read_dir(root.path().join("books")) + .unwrap() + .count(), + 1 + ); + // Adopted once only. + assert!(matches!( + root.adopt(&data, &id("acme"), &AdoptOptions::default()), + Err(RootError::Exists(_)) + )); +} + +#[test] +fn books_without_settings_need_them_and_get_them_on_the_way_in() { + let home = tmp("adopt-settings"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-settings-src"); + let source = source_books(old.path(), false); + let head = source.head(); + drop(source); + match root.adopt(old.path(), &id("acme"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => assert!(why.contains("keep no settings"), "{why}"), + other => panic!("{other:?}"), + } + assert!(root.list_books().unwrap().is_empty()); + let report = root + .adopt( + old.path(), + &id("acme"), + &AdoptOptions { + settings: Some(( + vec![set("company.name", json!("From The File LLC"))], + src("Owner"), + )), + ..AdoptOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.source_head, head); + assert_eq!(report.settings_imported, 1); + // What was added: the journal format the settings need, declared + // first (older books), then the settings. + let journal = std::fs::read_to_string(root.book_dir(&id("acme")).join(JOURNAL_FILE)).unwrap(); + let added: Vec = journal + .lines() + .skip(usize::try_from(head.0).unwrap()) + .map(|l| { + serde_json::from_str::(l).unwrap()["type"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + let format_declaration = Store::create(&tmp("probe").path().join("d"), Currency::USD) + .map(|mut s| { + s.adopt_settings(&[], &src("x")).unwrap(); + let j = std::fs::read_to_string(s.data_dir().join(JOURNAL_FILE)).unwrap(); + j.lines() + .map(|l| { + serde_json::from_str::(l).unwrap()["type"] + .as_str() + .unwrap() + .to_string() + }) + .collect::>() + }) + .unwrap(); + // The same steps a fresh store takes to keep settings, after store.created. + assert_eq!(added, format_declaration[1..].to_vec()); + assert_eq!(added.last().map(String::as_str), Some("settings.set")); + assert_eq!(report.head.0, head.0 + added.len() as u64); + let adopted = Store::open(&root.book_dir(&id("acme"))).unwrap(); + assert!(adopted.ledger().settings().is_journaled()); + assert_eq!( + adopted.ledger().settings().company("USD").name, + "From The File LLC" + ); +} + +#[test] +fn books_another_writer_has_open_are_refused() { + let home = tmp("adopt-locked"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-locked-src"); + let open = source_books(old.path(), true); + let err = root + .adopt(old.path(), &id("acme"), &AdoptOptions::default()) + .unwrap_err(); + assert!( + matches!(err.store_error(), Some(StoreError::Locked(_))), + "{err:?}" + ); + drop(open); + assert!(root.list_books().unwrap().is_empty()); + assert_eq!( + std::fs::read_dir(root.path().join("books")) + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn a_cut_off_or_damaged_source_is_refused_and_nothing_is_left_behind() { + let home = tmp("adopt-bad"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + + // A cut-off last line: refused, the source not repaired. + let torn = tmp("adopt-torn"); + drop(source_books(torn.path(), true)); + let journal = torn.path().join(JOURNAL_FILE); + let mut bytes = std::fs::read(&journal).unwrap(); + bytes.extend_from_slice(b"{\"seq\":99"); + std::fs::write(&journal, &bytes).unwrap(); + match root.adopt(torn.path(), &id("torn"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => assert!(why.contains("cut off"), "{why}"), + other => panic!("{other:?}"), + } + assert_eq!(std::fs::read(&journal).unwrap(), bytes); + + // A blob whose bytes changed: the copy fails its re-hash, and no book + // (nor its staging folder) appears. + let damaged = tmp("adopt-damaged"); + drop(source_books(damaged.path(), true)); + let blob = walk_files(&damaged.path().join("blobs")).remove(0); + std::fs::write(&blob, b"not what was stored").unwrap(); + match root.adopt(damaged.path(), &id("damaged"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => { + assert!(why.contains("doesn't match its fingerprint"), "{why}") + } + other => panic!("{other:?}"), + } + assert!(root.list_books().unwrap().is_empty()); + assert_eq!( + std::fs::read_dir(root.path().join("books")) + .unwrap() + .count(), + 0 + ); + + // A link among the blobs is refused. + let linked = tmp("adopt-linked"); + drop(source_books(linked.path(), true)); + std::os::unix::fs::symlink("/etc/hostname", linked.path().join("blobs/link")).unwrap(); + match root.adopt(linked.path(), &id("linked"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => assert!(why.contains("isn't a stored file"), "{why}"), + other => panic!("{other:?}"), + } + assert_eq!( + std::fs::read_dir(root.path().join("books")) + .unwrap() + .count(), + 0 + ); + + // Not books at all. + let empty = tmp("adopt-empty"); + match root.adopt(empty.path(), &id("empty"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => assert!(why.contains("no journal.jsonl"), "{why}"), + other => panic!("{other:?}"), + } +} + +#[test] +fn books_inside_the_root_are_not_adopted_into_it() { + let home = tmp("adopt-inside"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + drop(root.create_book(&id("acme"), Currency::USD).unwrap()); + match root.adopt( + &root.book_dir(&id("acme")), + &id("copy"), + &AdoptOptions::default(), + ) { + Err(RootError::Adopt(why)) => { + assert!(why.contains("inside the books' private folder"), "{why}") + } + other => panic!("{other:?}"), + } + // Nor the folder holding the root. + match root.adopt(home.path(), &id("copy"), &AdoptOptions::default()) { + Err(RootError::Adopt(_)) => {} + other => panic!("{other:?}"), + } +} + +fn walk_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + out.extend(walk_files(&p)); + } else { + out.push(p); + } + } + out.sort(); + out +} + +/// Append a line, properly chained, of a type this version doesn't know: +/// what a newer wanderbooks would write. +fn append_newer_line(data: &Path) { + use sha2::{Digest, Sha256}; + let journal = data.join(JOURNAL_FILE); + let text = std::fs::read_to_string(&journal).unwrap(); + let last: JournalLine = serde_json::from_str(text.lines().last().unwrap()).unwrap(); + let next = JournalLine { + seq: last.seq + 1, + ts: last.ts.clone(), + line_type: "future.thing".into(), + payload: json!({}), + prev: hex::encode(Sha256::digest(last.to_canonical().as_bytes())), + }; + std::fs::write(&journal, format!("{text}{}\n", next.to_canonical())).unwrap(); +} + +#[test] +fn setup_run_again_keeps_the_recorded_root_and_never_moves_books() { + let home = tmp("setup-keep"); + let chosen = home.path().join("chosen"); + let (root, _) = Root::setup(home.path(), Some(&chosen), None).unwrap(); + drop(root.create_book(&id("acme"), Currency::USD).unwrap()); + // Run again with nothing given (and XDG set): the recorded root stays. + let (again, report) = Root::setup(home.path(), None, Some(&home.path().join("xdg"))).unwrap(); + assert_eq!(again.path(), chosen); + assert!(report.recorded); + assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen); + // A root elsewhere while this one holds books: refused, nothing changed. + let other = home.path().join("other"); + match Root::setup(home.path(), Some(&other), None) { + Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"), + x => panic!("{x:?}"), + } + assert!(!other.exists()); + assert_eq!(Root::find_in(home.path(), None).unwrap().path(), chosen); + match Root::setup(home.path(), Some(&default_root(home.path())), None) { + Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"), + x => panic!("{x:?}"), + } +} + +#[test] +fn setup_leaves_a_folder_that_holds_other_things_alone() { + let home = tmp("setup-full"); + let full = home.path().join("Documents"); + std::fs::create_dir(&full).unwrap(); + std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(full.join("taxes.pdf"), "x").unwrap(); + match Root::setup(home.path(), Some(&full), None) { + Err(RootError::Unsafe(why)) => assert!(why.contains("holds other things"), "{why}"), + x => panic!("{x:?}"), + } + assert_eq!(mode(&full), 0o755, "its permissions untouched"); + assert!(!full.join("books").exists() && !full.join(MARKER_FILE).exists()); + assert!(!platform_data_dir(home.path()).join(LOCATION_FILE).exists()); + // An empty folder is fine. + let empty = home.path().join("empty"); + std::fs::create_dir(&empty).unwrap(); + std::fs::set_permissions(&empty, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!(Root::setup(home.path(), Some(&empty), None).is_ok()); +} + +#[test] +fn a_root_another_user_could_swap_is_refused() { + let home = tmp("ancestor"); + let open = home.path().join("open"); + std::fs::create_dir(&open).unwrap(); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + match Root::setup(home.path(), Some(&open.join("books-root")), None) { + Err(RootError::Unsafe(why)) => assert!(why.contains("a folder above"), "{why}"), + x => panic!("{x:?}"), + } + // With the sticky bit (like /tmp), others can't swap our folders. + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o1777)).unwrap(); + let (root, _) = Root::setup(home.path(), Some(&open.join("books-root")), None).unwrap(); + // And a parent opened up later is caught when the root is opened. + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(matches!(Root::open(root.path()), Err(RootError::Unsafe(_)))); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn missing_folders_above_the_root_are_made_private() { + let home = tmp("parents"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + assert_eq!(mode(&home.path().join(".local")), 0o700); + assert_eq!(mode(&home.path().join(".local/share")), 0o700); + assert_eq!(mode(&root.path().join(MARKER_FILE)), 0o600); +} + +#[test] +fn a_planted_location_note_is_refused() { + let home = tmp("planted"); + let data = platform_data_dir(home.path()); + std::fs::create_dir_all(&data).unwrap(); + let pointer = data.join(LOCATION_FILE); + std::fs::write(&pointer, format!("{}\n", home.path().join("x").display())).unwrap(); + std::fs::set_permissions(&pointer, std::fs::Permissions::from_mode(0o666)).unwrap(); + assert!(matches!( + Root::locate(home.path(), None), + Err(RootError::Unsafe(_)) + )); + assert!(matches!( + Root::setup(home.path(), None, None), + Err(RootError::Unsafe(_)) + )); + std::fs::remove_file(&pointer).unwrap(); + std::os::unix::fs::symlink(home.path().join("elsewhere"), &pointer).unwrap(); + assert!(matches!( + Root::locate(home.path(), None), + Err(RootError::Unsafe(_)) + )); +} + +#[test] +fn a_new_root_is_named_plainly() { + let home = tmp("plain"); + std::fs::create_dir(home.path().join("a")).unwrap(); + // `..` anywhere is refused, where it exists or not. + for odd in ["a/../b", "new/../c"] { + assert!( + matches!( + Root::setup(home.path(), Some(&home.path().join(odd)), None), + Err(RootError::Unsafe(_)) + ), + "{odd}" + ); + } + // Links in the folders above are resolved; the root's own name is kept. + std::os::unix::fs::symlink(home.path().join("a"), home.path().join("to-a")).unwrap(); + let (root, _) = + Root::setup(home.path(), Some(&home.path().join("to-a/new/books")), None).unwrap(); + assert_eq!( + root.path(), + std::fs::canonicalize(home.path()) + .unwrap() + .join("a/new/books") + ); +} + +/// Set every folder under `dir` to `dirs` and every file to `files`. +fn chmod_tree(dir: &Path, dirs: u32, files: u32) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + chmod_tree(&p, dirs, files); + } else { + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(files)).unwrap(); + } + } + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(dirs)).unwrap(); +} + +#[test] +fn adoption_leaves_a_read_only_source_as_it_was() { + let home = tmp("adopt-ro"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-ro-src"); + drop(source_books(old.path(), true)); + let before = snapshot_files(old.path()); + chmod_tree(old.path(), 0o500, 0o400); + let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default()); + chmod_tree(old.path(), 0o700, 0o600); + let report = report.unwrap(); + assert!(!report.source_lock_created); + assert_eq!(snapshot_files(old.path()), before, "the lock file included"); +} + +#[test] +fn a_source_with_no_lock_file_gets_one_and_says_so() { + let home = tmp("adopt-nolock"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-nolock-src"); + drop(source_books(old.path(), true)); + std::fs::remove_file(old.path().join("wanderbooks.lock")).unwrap(); + let report = root + .adopt(old.path(), &id("acme"), &AdoptOptions::default()) + .unwrap(); + assert!(report.source_lock_created); +} + +#[test] +fn a_vault_or_key_that_isnt_there_stops_adoption() { + let home = tmp("adopt-novault"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-novault-src"); + drop(source_books(old.path(), true)); + for options in [ + AdoptOptions { + vault_dir: Some(old.path().join("no-vault")), + ..AdoptOptions::default() + }, + AdoptOptions { + vault_key: Some(old.path().join("no.key")), + ..AdoptOptions::default() + }, + ] { + match root.adopt(old.path(), &id("acme"), &options) { + Err(RootError::Adopt(why)) => assert!(why.contains("isn't there"), "{why}"), + x => panic!("{x:?}"), + } + } + assert!(root.list_books().unwrap().is_empty()); +} + +#[test] +fn every_stored_file_is_accounted_for() { + use sha2::{Digest, Sha256}; + let home = tmp("adopt-blobs"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + + // A file the books name, missing: named in the refusal. + let missing = tmp("adopt-missing"); + drop(source_books(missing.path(), true)); + let gone = walk_files(&missing.path().join("blobs")).remove(0); + let sha = gone.file_name().unwrap().to_string_lossy().into_owned(); + std::fs::remove_file(&gone).unwrap(); + match root.adopt(missing.path(), &id("missing"), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => { + assert!(why.contains("aren't there") && why.contains(&sha), "{why}") + } + x => panic!("{x:?}"), + } + + // A stored file the books don't name, and an unfinished write. + let extra = tmp("adopt-extra"); + drop(source_books(extra.path(), true)); + let orphan = b"stored, never named"; + let osha = hex::encode(Sha256::digest(orphan)); + let odir = extra + .path() + .join("blobs") + .join(&osha[0..2]) + .join(&osha[2..4]); + std::fs::create_dir_all(&odir).unwrap(); + std::fs::write(odir.join(&osha), orphan).unwrap(); + std::fs::write(odir.join(format!("{osha}.tmp")), b"half").unwrap(); + // Hidden files a desktop leaves (macOS's .DS_Store), anywhere in it. + std::fs::write(extra.path().join("blobs/.DS_Store"), b"finder").unwrap(); + std::fs::write(odir.parent().unwrap().join(".DS_Store"), b"finder").unwrap(); + std::fs::write(odir.join(".DS_Store"), b"finder").unwrap(); + let report = root + .adopt(extra.path(), &id("extra"), &AdoptOptions::default()) + .unwrap(); + assert_eq!((report.blob_files, report.blobs_verified), (3, 2)); + assert_eq!((report.unnamed_blobs, report.skipped_files), (1, 4)); + let dir = root + .book_dir(&id("extra")) + .join("blobs") + .join(&osha[0..2]) + .join(&osha[2..4]); + assert!(dir.join(&osha).is_file() && !dir.join(format!("{osha}.tmp")).exists()); + + // A stray file where only stored files belong: refused. + let stray = tmp("adopt-stray"); + drop(source_books(stray.path(), true)); + std::fs::write(stray.path().join("blobs/notes.txt"), "x").unwrap(); + assert!(matches!( + root.adopt(stray.path(), &id("stray"), &AdoptOptions::default()), + Err(RootError::Adopt(_)) + )); + assert_eq!(root.list_books().unwrap(), vec![id("extra")]); +} + +#[test] +fn books_from_a_newer_wanderbooks_are_refused() { + let home = tmp("adopt-newer"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-newer-src"); + drop(source_books(old.path(), true)); + append_newer_line(old.path()); + let err = root + .adopt(old.path(), &id("acme"), &AdoptOptions::default()) + .unwrap_err(); + assert!( + matches!(err.store_error(), Some(StoreError::NewerJournal { .. })), + "{err:?}" + ); + assert!(root.list_books().unwrap().is_empty()); +} + +#[test] +fn an_adoption_that_never_finished_is_cleared_away() { + let home = tmp("adopt-stale"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let stale = root.path().join("books/.adopt-acme-999999"); + std::fs::create_dir(&stale).unwrap(); + std::fs::write(stale.join("journal.jsonl"), "half a copy").unwrap(); + let old = tmp("adopt-stale-src"); + drop(source_books(old.path(), true)); + root.adopt(old.path(), &id("acme"), &AdoptOptions::default()) + .unwrap(); + assert!(!stale.exists()); + assert_eq!( + std::fs::read_dir(root.path().join("books")) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn settings_given_for_books_that_keep_their_own_are_reported_unused() { + let home = tmp("adopt-ignored"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let old = tmp("adopt-ignored-src"); + let s = source_books(old.path(), true); + let head = s.head(); + drop(s); + let report = root + .adopt( + old.path(), + &id("acme"), + &AdoptOptions { + settings: Some((vec![set("company.name", json!("Other"))], src("x"))), + ..AdoptOptions::default() + }, + ) + .unwrap(); + assert!(report.settings_ignored); + assert_eq!((report.head, report.settings_imported), (head, 0)); +} + +#[test] +fn no_refusal_names_the_root() { + let home = tmp("hidden"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let path = root.path().display().to_string(); + drop(root.create_book(&id("acme"), Currency::USD).unwrap()); + let open = Store::open(&root.book_dir(&id("acme"))).unwrap(); + let mut errors = vec![ + refused(root.create_book(&id("acme"), Currency::USD)).to_string(), + root.adopt( + &root.book_dir(&id("acme")), + &id("x"), + &AdoptOptions::default(), + ) + .unwrap_err() + .to_string(), + ]; + drop(open); + let books = root.path().join("books"); + std::fs::set_permissions(&books, std::fs::Permissions::from_mode(0o755)).unwrap(); + errors.push(root.list_books().unwrap_err().to_string()); + errors.push(refused(root.create_book(&id("beta"), Currency::USD)).to_string()); + std::fs::set_permissions(&books, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + errors.push(Root::open(root.path()).unwrap_err().to_string()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + for e in errors { + assert!(!e.contains(&path), "names the root: {e}"); + assert!(!e.is_empty()); + } +} + +#[test] +fn setup_changes_nothing_under_a_folder_another_user_could_swap() { + let home = tmp("order"); + let open = home.path().join("open"); + std::fs::create_dir(&open).unwrap(); + let existing = open.join("existing"); + std::fs::create_dir(&existing).unwrap(); + std::fs::set_permissions(&existing, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap(); + // An existing folder there: refused before it is tightened or marked. + assert!(matches!( + Root::setup(home.path(), Some(&existing), None), + Err(RootError::Unsafe(_)) + )); + assert_eq!(mode(&existing), 0o755); + assert!(std::fs::read_dir(&existing).unwrap().next().is_none()); + // A new one there: refused before any folder is made. + assert!(matches!( + Root::setup(home.path(), Some(&open.join("new/books")), None), + Err(RootError::Unsafe(_)) + )); + assert!(!open.join("new").exists()); + std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn a_root_that_cant_be_opened_counts_as_holding_books() { + let home = tmp("loosened"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + drop(root.create_book(&id("acme"), Currency::USD).unwrap()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + let elsewhere = home.path().join("elsewhere"); + match Root::setup(home.path(), Some(&elsewhere), None) { + Err(RootError::Unsafe(why)) => assert!(why.contains("may hold books"), "{why}"), + x => panic!("{x:?}"), + } + assert!(!elsewhere.exists()); + // Via XDG_DATA_HOME, the refusal says so. + match Root::setup(home.path(), None, Some(&home.path().join("xdg"))) { + Err(RootError::Unsafe(why)) => assert!(why.contains("XDG_DATA_HOME"), "{why}"), + x => panic!("{x:?}"), + } + // Setup with nothing given makes it private again, books and all. + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn a_group_folder_of_this_account_s_own_private_group_is_fine() { + use std::os::unix::fs::MetadataExt; + let home = tmp("upg"); + let parent = home.path().join("share"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o775)).unwrap(); + let gid = std::fs::metadata(&parent).unwrap().gid(); + // SAFETY: getgid has no preconditions. + let primary = unsafe { libc::getgid() }; + let result = Root::setup(home.path(), Some(&parent.join("books")), None); + if gid == primary && private_group(primary) { + assert!(result.is_ok(), "{:?}", result.err()); + } else { + assert!(matches!(result, Err(RootError::Unsafe(_)))); + } +} + +/// The test's own view of a private group: the primary group, named as +/// the login is, no members listed (from /etc/group and $USER, when there). +fn private_group(gid: u32) -> bool { + let Ok(user) = std::env::var("USER") else { + return false; + }; + std::fs::read_to_string("/etc/group").is_ok_and(|groups| { + groups.lines().any(|l| { + let f: Vec<&str> = l.split(':').collect(); + f.len() >= 4 && f[0] == user && f[2] == gid.to_string() && f[3].is_empty() + }) + }) +} + +#[test] +fn a_book_id_prints_as_itself() { + assert_eq!(id("acme-2026").to_string(), "acme-2026"); + assert_eq!(id("acme-2026").as_str(), "acme-2026"); +} + +/// Something put under a source's `blobs/` for a test. +type Plant = Box; + +#[test] +fn only_stored_files_where_the_books_keep_them_are_adopted() { + use sha2::{Digest, Sha256}; + let home = tmp("adopt-strays"); + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let bytes = b"a real stored file"; + let sha = hex::encode(Sha256::digest(bytes)); + // Each case: something under blobs/ that isn't where a stored file + // sits. Every one is refused, with the same reason. + let cases: Vec<(&str, Plant)> = vec![ + ( + "a folder not named by two hex digits", + Box::new(|b: &Path| std::fs::create_dir(b.join("zz")).unwrap()), + ), + ( + "a folder named by three hex digits", + Box::new(|b: &Path| std::fs::create_dir(b.join("abc")).unwrap()), + ), + ( + "a file where a folder belongs", + Box::new(|b: &Path| std::fs::write(b.join("ab"), "x").unwrap()), + ), + ( + "a second-level folder not named by two hex digits", + Box::new(|b: &Path| std::fs::create_dir_all(b.join("ab/zz")).unwrap()), + ), + ( + "a second-level file where a folder belongs", + Box::new(|b: &Path| { + std::fs::create_dir_all(b.join("ab")).unwrap(); + std::fs::write(b.join("ab/cd"), "x").unwrap() + }), + ), + ( + "a stored file in the wrong folder", + Box::new(move |b: &Path| { + let wrong = if sha.starts_with("00") { "11" } else { "00" }; + std::fs::create_dir_all(b.join(wrong).join(&sha[2..4])).unwrap(); + std::fs::write(b.join(wrong).join(&sha[2..4]).join(&sha), bytes).unwrap() + }), + ), + ( + "a file named by 64 characters that aren't hex", + Box::new(|b: &Path| { + std::fs::create_dir_all(b.join("ab/cd")).unwrap(); + std::fs::write(b.join("ab/cd").join(format!("ab{}", "z".repeat(62))), "x").unwrap() + }), + ), + ]; + for (i, (what, plant)) in cases.iter().enumerate() { + let src = tmp(&format!("adopt-stray-{i}")); + drop(source_books(src.path(), true)); + plant(&src.path().join("blobs")); + match root.adopt(src.path(), &id(&format!("s{i}")), &AdoptOptions::default()) { + Err(RootError::Adopt(why)) => { + assert!(why.contains("isn't a stored file"), "{what}: {why}") + } + x => panic!("{what}: {x:?}"), + } + } + assert!(root.list_books().unwrap().is_empty()); +} + +#[test] +fn errors_other_than_not_found_are_never_taken_for_it() { + let home = tmp("not-not-found"); + // Opening a root under a file: the error, not "no root yet". + let file = home.path().join("file"); + std::fs::write(&file, "x").unwrap(); + assert!(matches!( + Root::open(&file.join("root")), + Err(RootError::Io(..)) + )); + // The data folder is a file: finding the root fails, never falls back. + let odd = tmp("not-not-found-home"); + std::fs::create_dir(odd.path().join(".local")).unwrap(); + std::fs::write(odd.path().join(".local/share"), "x").unwrap(); + assert!(matches!( + Root::locate(odd.path(), None), + Err(RootError::Io(..)) + )); + // A source whose lock file can't be read: refused, never recreated. + let (root, _) = Root::setup(home.path(), None, None).unwrap(); + let src = tmp("not-not-found-src"); + drop(source_books(src.path(), true)); + let lock = src.path().join("wanderbooks.lock"); + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o200)).unwrap(); + assert!(matches!( + root.adopt(src.path(), &id("acme"), &AdoptOptions::default()), + Err(RootError::Io(..)) + )); + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(root.list_books().unwrap().is_empty()); +} diff --git a/docs/testing.md b/docs/testing.md index 60faf3b..8003400 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -379,6 +379,33 @@ in the default build and the `run_gui` arms again in the server-only build (`--no-default-features`), every one caught in the build where its code exists. +## P2 slice 2: the data root (2026-10-18) + +`ledger_store::root` (plan 3.4, 5.5) and `wanderbooks setup`: +`tests/root.rs` (32), the module's unit tests (4), `ledger-cli/tests/setup.rs` +(3). Three review rounds (12 findings, then 5, then 1, all fixed). +Mutation on the diff, the viewer left out, two jobs: the CLI, 7 of 7 +caught; `root.rs`, 47 missed on the first run, then new tests (the blob +copier's strays, errors other than not-found, the buffer that grows +behind `getpwuid_r`/`getgrgid_r`, the private-group rule) and a rerun: +201 mutants, 163 caught, 23 unviable, 1 timeout (the growing buffer +halved instead of doubled, which never ends), and 14 left, none +reachable by a test: +- the lookups' failure guards (`rc != 0 || null || null` with `&&`, + five): on every lookup that succeeds they agree, and this account's + own lookup always succeeds; +- `NotFound` guards whose other errors end the same way (setup's + `symlink_metadata` of the chosen root, where creating fails with the + same error; the location note's removal, unreachable after the note + was checked to be a plain file; a subfolder's lookup inside a root + already opened); +- `create_book`'s clean-up after `Store::create` fails in a folder it + just made (no test can make that fail); +- `make_private_dirs`' `AlreadyExists` guard (two setups racing); +- `sync_dir` (durability, not visible to a test); +- the group's member check (`!` deleted), which agrees on every account + whose own group lists no members, the usual case. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (Stephen, 2026-10-13: the books do little work diff --git a/plan/served.md b/plan/served.md index 32cf3b1..6bcfdc3 100644 --- a/plan/served.md +++ b/plan/served.md @@ -216,7 +216,7 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has archive.git/ bare; journal.jsonl + blobs/ + vault ciphertext; age-encrypted on push; destination Q-1 ``` -The server, the CLI and the GUI resolve the data root with one shared function (`ledger-store::paths::data_root()`: the platform data directory, overridable with `--root`), never from the working directory, a project directory or a toml file. The root is created with an explicit `set_permissions(0o700)`, not the umask. Extracted text is stored as a content-addressed blob, recorded in the journal by `attachment.text_extracted`, and archived with the books. Page rasters are disposable. Deleted assumptions: `books_dir = data_dir.parent()` (S:1155, `ledger-gui/src/books.rs:1474`, `ledger-mcp/src/main.rs:102`), the `.gitignore` check (S:531-543), and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs:98-130`), which `ops/show.jsonl` replaces. +The server, the CLI and the GUI find the data root with one shared function (`ledger_store::root::Root::find`, built in P2 slice 2): `--root` if given, else the root `wanderbooks setup` recorded in `wanderbooks.location` beside the default, else the platform default under the account's home, taken from the password database on Unix, never `$HOME`. Finding it never reads the environment, the working directory, a project directory or a toml file; `$XDG_DATA_HOME` is honoured once, by `setup`, which records the result. The root and every folder made in it are created with an explicit 0700, not the umask, and a root that is a symlink, belongs to another user or that others can enter is refused (`setup` tightens one of ours). `setup` leaves a `.wanderbooks-root` marker, and a folder without one is never opened as a root nor, if it holds anything, made into one; every folder above the root must be one no other user can swap it in (owned by this account or the system, not writable by others unless sticky). Run again, `setup` keeps the root it recorded, and it never makes a second root or moves books while the current one holds any. Neither `setup` nor any refusal prints the root's location: nothing an agent is shown names it. Books are created and adopted one at a time, under `run/books.lock`. Windows (P11): `%LOCALAPPDATA%` from the known-folder API and an owner-only DACL; until then the Windows paths are read from the profile and carry no ACL. Extracted text is stored as a content-addressed blob, recorded in the journal by `attachment.text_extracted`, and archived with the books. Page rasters are disposable. Deleted assumptions: `books_dir = data_dir.parent()` (S:1155, `ledger-gui/src/books.rs:1474`, `ledger-mcp/src/main.rs:102`), the `.gitignore` check (S:531-543), and the `$XDG_RUNTIME_DIR` viewer file (`ledger-store/src/viewer.rs:98-130`), which `ops/show.jsonl` replaces. ### 3.5 Keys @@ -560,9 +560,9 @@ The `WANDERBOOKS_TOML` template (`ledger-cli/src/main.rs:237-347`) becomes the e - `wanderbooks books adopt --id X [--toml FILE] [--config-json FILE]` runs as the human, with no sudo, through the admin socket. The server, which also runs as the human, does the copy: 1. Takes the source flock, so it refuses while a stdio server holds it. 2. Verifies the chain and records head H. - 3. Copies `journal.jsonl` and `blobs/`, re-hashes every blob, rebuilds the projection, and checks that the head equals H. + 3. Copies `journal.jsonl` and `blobs/` into a staging folder (a cut-off last line or a link among the blobs is refused), opens the copy (rebuilding the projection), checks that the head equals H, verifies it end to end and re-hashes every blob; only then is it renamed into `books/X/`, and on any failure the staging folder is removed and nothing appears. A source inside the root, or holding it, is refused. 4. Imports settings from the given texts (preview printed, confirmation at the TTY) unless the journal already has `settings.set` events. - 5. Moves `vault/*.bin` into `books/X/vault/`, and the v1 key into `keys/legacy.key`. + 5. Copies the vault's records into `books/X/vault/`, and the v1 key into `keys/legacy.key` (0600). Copied, not moved (P2 slice 2): adoption never changes the source, so a refused or abandoned adoption leaves it exactly as it was; step 7 is what moves the old copies away. 6. Seeds `archive.git` from the source's git history, filtered to the journal and blob paths, and records the old repository's last commit hash. The push destination is the owner's answer to Q-1. 7. If the owner agrees (Q-3), moves the source data dir, its vault and the project folder's git history into `/legacy/X-/`. Nothing is deleted. If the owner says no, the source stays where it is, read-only, and Needs attention lists it as an exposed old copy. - `ledger-cli` `init`/`new` stop scaffolding and call `books create`.