P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an agent session or a terminal holds them: a host process takes leases on a socket, starts the server (the hidden mode `wanderbooks server`) with a pipe on its input, and closes it 10 s after the last lease ends. No background service. One host even in a race (run/host.lock). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
168e5c9846
commit
a298d154b6
31 files changed
+11937
-112
No files matched your search
+196
-36
@@ -58,13 +58,19 @@ pub const BOOKS_DIR: &str = "books";
|
||||
pub const RUN_DIR: &str = "run";
|
||||
/// Old copies moved aside at adoption, never deleted (plan 5.5).
|
||||
pub const LEGACY_DIR: &str = "legacy";
|
||||
/// A book's TIN vault folder, in its own folder.
|
||||
pub const VAULT_DIR: &str = "vault";
|
||||
/// A book's key folder, in its own folder.
|
||||
pub const KEYS_DIR: &str = "keys";
|
||||
/// The v1 vault key, in a book's `keys/` (P2 to P7).
|
||||
pub const LEGACY_KEY_FILE: &str = "legacy.key";
|
||||
/// Held while books are created or adopted, so only one is made at a time.
|
||||
const BOOKS_LOCK: &str = "books.lock";
|
||||
/// Staging folders under `books/` start so; none is a valid book id.
|
||||
const STAGING_PREFIX: &str = ".adopt-";
|
||||
|
||||
/// How the root is named in messages (INVARIANT 6).
|
||||
const THE_ROOT: &str = "the books' private folder";
|
||||
pub const THE_ROOT: &str = "the books' private folder";
|
||||
|
||||
/// Why a root or book operation was refused. No message names the root or
|
||||
/// a path inside it (INVARIANT 6).
|
||||
@@ -244,6 +250,91 @@ pub fn default_root(home: &Path) -> PathBuf {
|
||||
platform_data_dir(home).join(ROOT_NAME)
|
||||
}
|
||||
|
||||
/// Whether `path` is a data root or inside one (any folder above it, or
|
||||
/// it, holds the marker `setup` leaves). Books there belong to the books
|
||||
/// server alone (plan 3.1: `dev-stdio` refuses them).
|
||||
pub fn inside_a_root(path: &Path) -> bool {
|
||||
// From here (a path that is already absolute stays as it is).
|
||||
let path = std::env::current_dir()
|
||||
.map(|here| here.join(path))
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
// Where the deepest folder that exists really is: a folder not made
|
||||
// yet holds no marker, and a link counts as where it leads.
|
||||
let Some(existing) = path.ancestors().find(|dir| dir.exists()) else {
|
||||
return false;
|
||||
};
|
||||
let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
|
||||
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
|
||||
}
|
||||
|
||||
/// A folder directly in a book's own folder (`keys/`, `vault/`): made
|
||||
/// 0700 if missing, made 0700 again if it is ours but others could enter
|
||||
/// it; refused if it is a symlink or belongs to another user. The error
|
||||
/// names no path (INVARIANT 6).
|
||||
pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootError> {
|
||||
let dir = book_dir.join(name);
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) => {
|
||||
let open = check_private_dir(&meta, true, "a folder inside the books")?;
|
||||
tighten_if(&dir, open)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
make_private_dir(&dir)?;
|
||||
sync_dir(book_dir)?;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The longest socket path used as it is: a Unix socket's address holds
|
||||
/// 104 bytes on macOS and 108 on Linux, the terminating zero included.
|
||||
const SOCKET_PATH_MAX: usize = 100;
|
||||
|
||||
/// The path to a socket named `name` in `dir`, short enough for the
|
||||
/// system: the path itself when it fits; else, on Linux, the same place
|
||||
/// reached through `/proc/self/fd` and a handle on `dir` kept open while
|
||||
/// this lives (it is good only in this process). Elsewhere a path too
|
||||
/// long is refused, saying so plainly (no path named).
|
||||
#[derive(Debug)]
|
||||
pub struct SocketPath {
|
||||
path: PathBuf,
|
||||
_dir: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl SocketPath {
|
||||
/// The socket `name` in `dir`.
|
||||
pub fn new(dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let full = dir.join(name);
|
||||
if full.as_os_str().len() <= SOCKET_PATH_MAX {
|
||||
return Ok(Self {
|
||||
path: full,
|
||||
_dir: None,
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
let handle = std::fs::File::open(dir)?;
|
||||
let path = PathBuf::from(format!("/proc/self/fd/{}/{name}", handle.as_raw_fd()));
|
||||
Ok(Self {
|
||||
path,
|
||||
_dir: Some(handle),
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"the books' private folder's path is too long for its sockets: choose a shorter place with `wanderbooks setup --root`",
|
||||
))
|
||||
}
|
||||
|
||||
/// The path to bind or connect to.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// The data root.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Root {
|
||||
@@ -422,6 +513,14 @@ impl Root {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The root's `run/` folder (its sockets and locks), made 0700 if
|
||||
/// missing and refused if it isn't private.
|
||||
pub fn run_dir(&self) -> Result<PathBuf, RootError> {
|
||||
self.private_subdir(RUN_DIR, false)
|
||||
.map(|(dir, _)| dir)
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
|
||||
/// The folder a book lives in.
|
||||
pub fn book_dir(&self, id: &BookId) -> PathBuf {
|
||||
self.path.join(BOOKS_DIR).join(id.as_str())
|
||||
@@ -562,22 +661,7 @@ impl Root {
|
||||
if std::fs::symlink_metadata(&dest).is_ok() {
|
||||
return Err(RootError::Exists(id.to_string()));
|
||||
}
|
||||
let source = std::fs::canonicalize(source).map_err(|e| {
|
||||
RootError::Adopt(format!("can't read the books at {}: {e}", source.display()))
|
||||
})?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
let journal_path = source.join(JOURNAL_FILE);
|
||||
if !journal_path.is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books at {}: it has no {JOURNAL_FILE}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
let source = self.adoptable(source)?;
|
||||
for (what, path) in [
|
||||
("vault folder", &options.vault_dir),
|
||||
("vault key", &options.vault_key),
|
||||
@@ -586,8 +670,7 @@ impl Root {
|
||||
&& !path.exists()
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"the {what} given, {}, isn't there: nothing was adopted",
|
||||
path.display()
|
||||
"the {what} given isn't there: nothing was adopted"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -595,16 +678,17 @@ impl Root {
|
||||
// writes it while it is copied.
|
||||
let (_source_lock, source_lock_created) = lock_source(&source)?;
|
||||
// Step 2: verify the chain and record the head.
|
||||
let raw = std::fs::read(&journal_path)?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
let (raw, lines) = read_source_journal(&source)?;
|
||||
let head = head_of(&lines);
|
||||
if let Some(expected) = &options.expect_head
|
||||
&& *expected != head
|
||||
{
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books changed since the preview (they are now at event {}): nothing was \
|
||||
adopted; preview them again",
|
||||
head.0
|
||||
)));
|
||||
}
|
||||
let keeps_settings = lines.iter().any(|l| l.line_type == "settings.set");
|
||||
if !keeps_settings && options.settings.is_none() {
|
||||
return Err(RootError::Adopt(
|
||||
@@ -634,14 +718,14 @@ impl Root {
|
||||
let copied = copy_blobs(&source.join(blob_dir), &staging.join(blob_dir))?;
|
||||
// Step 5: the vault's records and the v1 key.
|
||||
let vault_files = match &options.vault_dir {
|
||||
Some(dir) => copy_flat_private(dir, &staging.join("vault"))?,
|
||||
Some(dir) => copy_flat_private(dir, &staging.join(VAULT_DIR))?,
|
||||
None => 0,
|
||||
};
|
||||
let key_copied = match &options.vault_key {
|
||||
Some(key) => {
|
||||
let keys = staging.join("keys");
|
||||
let keys = staging.join(KEYS_DIR);
|
||||
make_private_dir(&keys)?;
|
||||
write_private_file(&keys.join("legacy.key"), &std::fs::read(key)?)?;
|
||||
write_private_file(&keys.join(LEGACY_KEY_FILE), &std::fs::read(key)?)?;
|
||||
sync_dir(&keys)?;
|
||||
true
|
||||
}
|
||||
@@ -708,6 +792,79 @@ impl Root {
|
||||
}
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The source of an adoption, made absolute: books outside the root
|
||||
/// (and not holding it) with a journal.
|
||||
fn adoptable(&self, source: &Path) -> Result<PathBuf, RootError> {
|
||||
let source = std::fs::canonicalize(source)
|
||||
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
|
||||
let root = std::fs::canonicalize(&self.path)?;
|
||||
if source.starts_with(&root) || root.starts_with(&source) {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||
)));
|
||||
}
|
||||
if !source.join(JOURNAL_FILE).is_file() {
|
||||
return Err(RootError::Adopt(format!(
|
||||
"there are no books there: it has no {JOURNAL_FILE}"
|
||||
)));
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// What adopting `source` would start from: its head, its base
|
||||
/// currency, and whether it keeps its own settings, read under the
|
||||
/// checks [`Self::adopt`] makes, writing nothing (not even a lock
|
||||
/// file). The adoption itself reads it all again under the lock.
|
||||
pub fn adopt_preview(&self, source: &Path) -> Result<SourcePreview, RootError> {
|
||||
(|| {
|
||||
let source = self.adoptable(source)?;
|
||||
// Held for writing now: adoption would be refused, so is this.
|
||||
if let Ok(lock) = std::fs::File::open(source.join(crate::store::LOCK_FILE))
|
||||
&& let Err(std::fs::TryLockError::WouldBlock) = lock.try_lock_shared()
|
||||
{
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
let (_, lines) = read_source_journal(&source)?;
|
||||
Ok(SourcePreview {
|
||||
head: head_of(&lines),
|
||||
currency: crate::store::journal_currency(&lines)?,
|
||||
keeps_settings: lines.iter().any(|l| l.line_type == "settings.set"),
|
||||
})
|
||||
})()
|
||||
.map_err(|e| self.hide(e))
|
||||
}
|
||||
}
|
||||
|
||||
/// The source's journal, whole and verified: its bytes and lines.
|
||||
fn read_source_journal(source: &Path) -> Result<(Vec<u8>, Vec<crate::JournalLine>), RootError> {
|
||||
let raw = std::fs::read(source.join(JOURNAL_FILE))?;
|
||||
if raw.last() != Some(&b'\n') {
|
||||
return Err(RootError::Adopt(
|
||||
"the source's last journal line is cut off: open the books once with the server \
|
||||
that wrote them (it repairs a cut-off line), then adopt them"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let (lines, _format) = crate::journal::verify_journal_and_format(&raw)?;
|
||||
Ok((raw, lines))
|
||||
}
|
||||
|
||||
/// What [`Root::adopt_preview`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SourcePreview {
|
||||
/// The source's head, `(seq, hash)`.
|
||||
pub head: (u64, String),
|
||||
/// Its base currency.
|
||||
pub currency: Currency,
|
||||
/// Whether it keeps its own settings.
|
||||
pub keeps_settings: bool,
|
||||
}
|
||||
|
||||
/// Options for [`Root::adopt`].
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AdoptOptions {
|
||||
@@ -725,6 +882,9 @@ pub struct AdoptOptions {
|
||||
/// The v1 vault key file: copied to the book's `keys/legacy.key`.
|
||||
/// Given, it must exist.
|
||||
pub vault_key: Option<PathBuf>,
|
||||
/// The head the source must have, `(seq, hash)`, as a preview read
|
||||
/// it: if the books changed since, nothing is adopted.
|
||||
pub expect_head: Option<(u64, String)>,
|
||||
}
|
||||
|
||||
/// What [`Root::adopt`] did.
|
||||
@@ -779,11 +939,11 @@ fn lock_source(source: &Path) -> Result<(std::fs::File, bool), RootError> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => {
|
||||
return Err(RootError::from(StoreError::Locked(format!(
|
||||
"another wanderbooks process has the books in {} open for writing (an agent \
|
||||
session, or its MCP server); close it first",
|
||||
source.display()
|
||||
))));
|
||||
return Err(RootError::from(StoreError::Locked(
|
||||
"another wanderbooks process has those books open for writing (an agent \
|
||||
session, or its MCP server); close it first"
|
||||
.into(),
|
||||
)));
|
||||
}
|
||||
Err(std::fs::TryLockError::Error(e)) => return Err(e.into()),
|
||||
}
|
||||
|
||||
@@ -6235,7 +6235,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||
|
||||
/// The base currency of a journal: its first line's, which must be a
|
||||
/// sound `store.created` ([`created_currency`]).
|
||||
fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
pub(crate) fn journal_currency(lines: &[JournalLine]) -> Result<Currency, StoreError> {
|
||||
let first = lines
|
||||
.first()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("journal has no store.created line".into()))?;
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn command_path(data_dir: &Path) -> PathBuf {
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
let path = command_path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
private_folder(dir)?;
|
||||
}
|
||||
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
|
||||
let cmd = ViewerCommand {
|
||||
@@ -129,6 +129,42 @@ pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerC
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// The folder the requests go in: made private (0700) if missing, made
|
||||
/// private again if ours; refused if it is a link or another account's
|
||||
/// (in a shared temp folder anyone could have planted one, to have a
|
||||
/// request written through it).
|
||||
fn private_folder(dir: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
if let Some(parent) = dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(meta) => {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
let me = unsafe { libc::geteuid() };
|
||||
if !meta.is_dir() || meta.uid() != me {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the viewer's request folder isn't this account's own",
|
||||
));
|
||||
}
|
||||
// Private again, whatever it was.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir(dir)?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::create_dir_all(dir)
|
||||
}
|
||||
|
||||
/// The latest request for `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
let bytes = std::fs::read(command_path(data_dir)).ok()?;
|
||||
@@ -170,6 +206,26 @@ mod tests {
|
||||
);
|
||||
// Same books, same file; other books, another.
|
||||
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// Made private; made private again.
|
||||
let folder = base.join("wanderbooks");
|
||||
private_folder(&folder).unwrap();
|
||||
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
private_folder(&folder).unwrap();
|
||||
assert_eq!(mode(&folder), 0o700);
|
||||
// A link planted in its place is refused.
|
||||
let link = base.join("planted");
|
||||
std::os::unix::fs::symlink(&base, &link).unwrap();
|
||||
assert!(private_folder(&link).is_err());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
|
||||
let _ = std::fs::remove_file(command_path(&dir));
|
||||
}
|
||||
|
||||
@@ -316,6 +316,8 @@ fn adopted_books_are_the_same_books_and_the_source_is_untouched() {
|
||||
settings: None,
|
||||
vault_dir: Some(vault.clone()),
|
||||
vault_key: Some(key.clone()),
|
||||
// As a preview read it: the same head, so it goes ahead.
|
||||
expect_head: Some(source_head.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -724,8 +726,16 @@ fn adoption_leaves_a_read_only_source_as_it_was() {
|
||||
drop(source_books(old.path(), true));
|
||||
let before = snapshot_files(old.path());
|
||||
chmod_tree(old.path(), 0o500, 0o400);
|
||||
// Writable again however the test ends, so its folder can go.
|
||||
struct Writable<'a>(&'a Path);
|
||||
impl Drop for Writable<'_> {
|
||||
fn drop(&mut self) {
|
||||
chmod_tree(self.0, 0o700, 0o600);
|
||||
}
|
||||
}
|
||||
let writable = Writable(old.path());
|
||||
let report = root.adopt(old.path(), &id("acme"), &AdoptOptions::default());
|
||||
chmod_tree(old.path(), 0o700, 0o600);
|
||||
drop(writable);
|
||||
let report = report.unwrap();
|
||||
assert!(!report.source_lock_created);
|
||||
assert_eq!(snapshot_files(old.path()), before, "the lock file included");
|
||||
@@ -1099,3 +1109,178 @@ fn errors_other_than_not_found_are_never_taken_for_it() {
|
||||
std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adoption_preview_reads_the_source_and_writes_nothing() {
|
||||
let home = tmp("preview");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("preview-src");
|
||||
let s = source_books(old.path(), false);
|
||||
let head = s.head();
|
||||
drop(s);
|
||||
// No lock file beside the journal: the preview must not make one.
|
||||
let _ = std::fs::remove_file(old.path().join("wanderbooks.lock"));
|
||||
let before = snapshot_files(old.path());
|
||||
let found = root.adopt_preview(old.path()).unwrap();
|
||||
assert_eq!(found.head, head);
|
||||
assert_eq!(found.currency, Currency::USD);
|
||||
assert!(!found.keeps_settings);
|
||||
assert_eq!(
|
||||
snapshot_files(old.path()),
|
||||
before,
|
||||
"the preview wrote to the source"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
// Books that keep their own settings say so.
|
||||
let kept = tmp("preview-kept");
|
||||
drop(source_books(kept.path(), true));
|
||||
assert!(root.adopt_preview(kept.path()).unwrap().keeps_settings);
|
||||
// The preview refuses what adoption refuses, naming no root.
|
||||
let book = root
|
||||
.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap();
|
||||
drop(book);
|
||||
let root_text = root.path().display().to_string();
|
||||
for bad in [
|
||||
root.book_dir(&BookId::parse("acme").unwrap()),
|
||||
root.path().to_path_buf(),
|
||||
home.path().join("missing"),
|
||||
home.path().to_path_buf(),
|
||||
] {
|
||||
let e = root.adopt_preview(&bad).unwrap_err();
|
||||
assert!(matches!(e, RootError::Adopt(_)), "{bad:?}: {e:?}");
|
||||
assert!(!e.to_string().contains(&root_text), "{e}");
|
||||
}
|
||||
// A cut-off last line is refused, as adoption refuses it.
|
||||
let cut = tmp("preview-cut");
|
||||
drop(source_books(cut.path(), false));
|
||||
let journal = cut.path().join(JOURNAL_FILE);
|
||||
let mut raw = std::fs::read(&journal).unwrap();
|
||||
raw.pop();
|
||||
std::fs::write(&journal, raw).unwrap();
|
||||
assert!(
|
||||
matches!(root.adopt_preview(cut.path()), Err(RootError::Adopt(why)) if why.contains("cut off"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_folder_is_made_private_and_a_shared_one_refused() {
|
||||
let home = tmp("bookfolder");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
drop(
|
||||
root.create_book(&BookId::parse("acme").unwrap(), Currency::USD)
|
||||
.unwrap(),
|
||||
);
|
||||
let book = root.book_dir(&BookId::parse("acme").unwrap());
|
||||
let keys = ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(keys, book.join("keys"));
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
// Again: already there, fine.
|
||||
assert_eq!(
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap(),
|
||||
keys
|
||||
);
|
||||
// Ours, but others could enter it: made private again.
|
||||
std::fs::set_permissions(&keys, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
ledger_store::root::private_book_folder(&book, "keys").unwrap();
|
||||
assert_eq!(mode(&keys), 0o700);
|
||||
std::os::unix::fs::symlink(home.path(), book.join("vault")).unwrap();
|
||||
assert!(ledger_store::root::private_book_folder(&book, "vault").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_run_folder_is_private() {
|
||||
let home = tmp("run");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let run = root.run_dir().unwrap();
|
||||
assert_eq!(run, root.path().join("run"));
|
||||
assert_eq!(mode(&run), 0o700);
|
||||
std::fs::remove_dir(&run).unwrap();
|
||||
assert_eq!(mode(&root.run_dir().unwrap()), 0o700, "remade private");
|
||||
std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(matches!(root.run_dir(), Err(RootError::Unsafe(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_root_is_known_by_its_marker() {
|
||||
let home = tmp("inside");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
use ledger_store::root::inside_a_root;
|
||||
assert!(inside_a_root(root.path()));
|
||||
assert!(inside_a_root(&root.path().join("books/acme")));
|
||||
assert!(inside_a_root(&root.path().join("books/not/yet/made")));
|
||||
assert!(!inside_a_root(home.path()));
|
||||
// A path relative to where the process runs is taken from there.
|
||||
let here = std::env::current_dir().unwrap();
|
||||
let up = "../".repeat(here.components().count() - 1);
|
||||
let relative = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
root.path()
|
||||
.join("books/acme")
|
||||
.display()
|
||||
.to_string()
|
||||
.trim_start_matches('/')
|
||||
));
|
||||
assert!(relative.is_relative());
|
||||
assert!(inside_a_root(&relative));
|
||||
let outside = PathBuf::from(format!(
|
||||
"{up}{}",
|
||||
home.path().display().to_string().trim_start_matches('/')
|
||||
));
|
||||
assert!(!inside_a_root(&outside));
|
||||
assert!(!inside_a_root(&home.path().join("elsewhere")));
|
||||
// Through a link, too: it is where the path leads that counts.
|
||||
let link = home.path().join("link");
|
||||
std::os::unix::fs::symlink(root.path().join("books"), &link).unwrap();
|
||||
assert!(inside_a_root(&link));
|
||||
assert!(!root.path().join(MARKER_FILE).is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_that_changed_since_the_preview_are_not_adopted() {
|
||||
let home = tmp("expect");
|
||||
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||
let old = tmp("expect-src");
|
||||
drop(source_books(old.path(), true));
|
||||
let previewed = root.adopt_preview(old.path()).unwrap().head;
|
||||
// The old server writes one more event after the preview.
|
||||
let mut s = Store::open(old.path()).unwrap();
|
||||
s.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let now = s.head();
|
||||
// While it holds them, a preview is refused as adoption would be.
|
||||
assert!(matches!(
|
||||
root.adopt_preview(old.path()),
|
||||
Err(RootError::Store(_, StoreError::Locked(_)))
|
||||
));
|
||||
drop(s);
|
||||
let before = snapshot_files(old.path());
|
||||
let e = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(previewed),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, RootError::Adopt(why) if why.contains("changed since the preview")),
|
||||
"{e:?}"
|
||||
);
|
||||
assert!(root.list_books().unwrap().is_empty());
|
||||
assert_eq!(snapshot_files(old.path()), before);
|
||||
// Previewed again, it goes ahead.
|
||||
let report = root
|
||||
.adopt(
|
||||
old.path(),
|
||||
&id("acme"),
|
||||
&AdoptOptions {
|
||||
expect_head: Some(now.clone()),
|
||||
..AdoptOptions::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report.source_head, now);
|
||||
}
|
||||
Reference in new issue
Block a user