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`.