P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+373
View File
@@ -0,0 +1,373 @@
//! Files on their way out (plan 4.7, D-29): a file the server makes for an
//! agent (the CPA package, the 1099 worksheet, a W-9) is kept here a
//! while, for the token that asked for it, until `wanderbooks download`
//! fetches it.
//!
//! ```text
//! GOAL: an agent hands the human a file of any size the books make,
//! without its bytes passing through the agent's context or naming a
//! path on the server.
//! SOURCE: plan 4.7 (`download_link`: "valid 10 minutes, access-logged");
//! D-29 ("their files are made by the server and handed over by a
//! command, `wanderbooks download`").
//! INVARIANTS:
//! 1. An export is kept for one token, under an id of no digits (the
//! access log masks what could be a TIN), and lasts EXPORT_TTL; only
//! that token fetches it, as often as it likes until it lapses.
//! 2. It is written aside and renamed whole: a fetch gets the whole file
//! or none, and its bytes hash to the SHA-256 kept with it.
//! 3. Exports live in the book's `exports/` folder (0700, files 0600);
//! lapsed ones are swept when the books open, whenever one is made,
//! and by the server's sweeper. A token has at most MAX_LIVE.
//! 4. No error names a path.
//! ```
use crate::journal::sha256_hex;
use crate::uploads::{file_name_problem, mime_problem};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::{Path, PathBuf};
/// The exports' folder, in a book's folder.
pub const EXPORTS_DIR: &str = "exports";
/// How long an export is kept, seconds.
pub const EXPORT_TTL: i64 = 15 * 60;
/// The most exports one token may have kept at once (INVARIANT 3).
pub const MAX_LIVE: usize = 16;
/// What an export is (INVARIANT 1): its bytes are beside it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Export {
/// Its id: `ex_` and 32 letters `a` to `p`.
pub id: String,
/// The token it was made for.
pub token: String,
/// The file's name.
pub file_name: String,
/// Its MIME type.
pub mime: String,
/// Its size.
pub size: u64,
/// Its SHA-256, hex.
pub sha256: String,
/// When it lapses (Unix seconds).
pub expires: i64,
}
/// Why an export can't be kept or fetched. Never names a path.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ExportError {
/// No such export, or it lapsed.
#[error("there is no export {0:?}, or it lapsed (an export is kept 15 minutes): make it again")]
Unknown(String),
/// Another token's.
#[error("that export was made for another token")]
NotYours,
/// Kept, but not whole (changed on the disk).
#[error("that export was damaged on the disk: make it again")]
Damaged,
/// A request that can't make one.
#[error("{0}")]
Bad(String),
/// The disk refused.
#[error("the export couldn't be kept: {0}")]
Io(std::io::ErrorKind),
}
impl From<std::io::Error> for ExportError {
fn from(e: std::io::Error) -> Self {
ExportError::Io(e.kind())
}
}
/// A book's exports.
#[derive(Debug, Clone)]
pub struct Exports {
dir: PathBuf,
/// Held while exports are made or swept (INVARIANT 3).
lock: std::sync::Arc<std::sync::Mutex<()>>,
}
/// `ex_` and 32 letters `a` to `p`, a letter a nibble (INVARIANT 1).
pub fn is_id(id: &str) -> bool {
id.len() == 3 + 32
&& id.starts_with("ex_")
&& id[3..].bytes().all(|b| (b'a'..=b'p').contains(&b))
}
fn new_id(raw: &[u8; 16]) -> String {
let mut id = String::from("ex_");
for b in raw {
id.push(char::from(b'a' + (b >> 4)));
id.push(char::from(b'a' + (b & 0x0f)));
}
id
}
fn private_new(path: &Path) -> std::io::Result<std::fs::File> {
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
o.open(path)
}
impl Exports {
/// The exports of the book in `book_dir` (its folder made, private, if
/// missing), lapsed ones swept.
pub fn open(book_dir: &Path, now: i64) -> Result<Self, ExportError> {
let dir = crate::root::private_book_folder(book_dir, EXPORTS_DIR)
.map_err(|e| ExportError::Bad(format!("the exports can't be kept: {e}")))?;
let exports = Self {
dir,
lock: Default::default(),
};
exports.sweep(now);
Ok(exports)
}
fn locked(&self) -> std::sync::MutexGuard<'_, ()> {
self.lock.lock().unwrap_or_else(|e| e.into_inner())
}
fn path(&self, id: &str, ext: &str) -> PathBuf {
self.dir.join(format!("{id}.{ext}"))
}
/// Keep `bytes` as `file_name` for `token` (INVARIANTS 1-3).
pub fn put(
&self,
token: &str,
file_name: &str,
mime: &str,
bytes: &[u8],
now: i64,
) -> Result<Export, ExportError> {
if let Some(why) = file_name_problem(file_name).or_else(|| mime_problem(mime)) {
return Err(ExportError::Bad(why));
}
let _held = self.locked();
self.sweep_held(now);
if self.live_for(token, now) >= MAX_LIVE {
return Err(ExportError::Bad(format!(
"this agent has {MAX_LIVE} exports kept: each lapses 15 minutes after it was made; make this one again then"
)));
}
let mut raw = [0u8; 16];
getrandom::getrandom(&mut raw)
.map_err(|_| ExportError::Bad("the system gave no randomness".into()))?;
let export = Export {
id: new_id(&raw),
token: token.to_string(),
file_name: file_name.to_string(),
mime: mime.to_string(),
size: bytes.len() as u64,
sha256: sha256_hex(bytes),
expires: now + EXPORT_TTL,
};
// The bytes first, then the record that names them: each written
// aside and renamed (INVARIANT 2).
for (ext, content) in [
("bin", bytes.to_vec()),
(
"json",
serde_json::to_vec(&export).expect("an export serializes"),
),
] {
let aside = self.path(&export.id, &format!("{ext}.new"));
let mut f = private_new(&aside)?;
f.write_all(&content)?;
f.sync_all()?;
drop(f);
std::fs::rename(&aside, self.path(&export.id, ext))?;
}
Ok(export)
}
/// Export `id` for `token` and its bytes, checked (INVARIANTS 1, 2).
pub fn get(&self, id: &str, token: &str, now: i64) -> Result<(Export, Vec<u8>), ExportError> {
if !is_id(id) {
return Err(ExportError::Unknown(id.chars().take(40).collect()));
}
let unknown = || ExportError::Unknown(id.to_string());
let record = std::fs::read(self.path(id, "json")).map_err(|_| unknown())?;
let export: Export = serde_json::from_slice(&record).map_err(|_| unknown())?;
if export.expires <= now {
return Err(unknown());
}
if export.token != token {
return Err(ExportError::NotYours);
}
let bytes = std::fs::read(self.path(id, "bin")).map_err(|_| unknown())?;
if bytes.len() as u64 != export.size || sha256_hex(&bytes) != export.sha256 {
return Err(ExportError::Damaged);
}
Ok((export, bytes))
}
/// How many exports `token` has kept at `now`.
fn live_for(&self, token: &str, now: i64) -> usize {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return 0;
};
entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".json"))
.filter_map(|e| std::fs::read(e.path()).ok())
.filter_map(|b| serde_json::from_slice::<Export>(&b).ok())
.filter(|x| x.token == token && x.expires > now)
.count()
}
/// Sweep away every export that lapsed by `now`, and anything left
/// without one (INVARIANT 3).
pub fn sweep(&self, now: i64) {
let _held = self.locked();
self.sweep_held(now);
}
fn sweep_held(&self, now: i64) {
let Ok(entries) = std::fs::read_dir(&self.dir) else {
return;
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
let Some((id, _)) = name.split_once('.') else {
let _ = std::fs::remove_file(e.path());
continue;
};
let lapsed = std::fs::read(self.path(id, "json"))
.ok()
.and_then(|b| serde_json::from_slice::<Export>(&b).ok())
.is_none_or(|x| x.expires <= now);
if lapsed {
let _ = std::fs::remove_file(e.path());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_800_000_000;
#[test]
fn an_export_is_fetched_by_its_token_until_it_lapses() {
let dir = tempfile::tempdir().unwrap();
let x = Exports::open(dir.path(), NOW).unwrap();
let bytes = b"PK cpa package".to_vec();
let e = x
.put("tok_a", "cpa-2026.zip", "application/zip", &bytes, NOW)
.unwrap();
assert!(is_id(&e.id));
assert!(!e.id[3..].bytes().any(|b| b.is_ascii_digit()));
assert_eq!(e.expires, NOW + EXPORT_TTL);
assert_eq!(e.sha256, sha256_hex(&bytes));
// As often as it likes, by its own token only.
for _ in 0..2 {
let (got, b) = x.get(&e.id, "tok_a", NOW + 1).unwrap();
assert_eq!((got, b), (e.clone(), bytes.clone()));
}
assert_eq!(
x.get(&e.id, "tok_b", NOW).unwrap_err(),
ExportError::NotYours
);
assert!(matches!(
x.get(&e.id, "tok_a", NOW + EXPORT_TTL).unwrap_err(),
ExportError::Unknown(_)
));
for bad in [
"../../journal",
"ex_",
"up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"",
] {
assert!(matches!(
x.get(bad, "tok_a", NOW).unwrap_err(),
ExportError::Unknown(_)
));
}
// Swept once lapsed, nothing left.
x.sweep(NOW + EXPORT_TTL);
assert_eq!(
std::fs::read_dir(dir.path().join(EXPORTS_DIR))
.unwrap()
.count(),
0
);
}
#[test]
fn a_damaged_export_is_never_handed_over() {
let dir = tempfile::tempdir().unwrap();
let x = Exports::open(dir.path(), NOW).unwrap();
let e = x
.put("t", "w9.pdf", "application/pdf", b"%PDF-1.4 W-9", NOW)
.unwrap();
let bin = dir.path().join(EXPORTS_DIR).join(format!("{}.bin", e.id));
std::fs::write(&bin, b"%PDF-1.4 W-8").unwrap();
assert_eq!(x.get(&e.id, "t", NOW).unwrap_err(), ExportError::Damaged);
std::fs::write(&bin, b"%PDF").unwrap();
assert_eq!(x.get(&e.id, "t", NOW).unwrap_err(), ExportError::Damaged);
}
#[test]
fn a_token_keeps_only_so_many_and_names_are_plain() {
let dir = tempfile::tempdir().unwrap();
let x = Exports::open(dir.path(), NOW).unwrap();
for _ in 0..MAX_LIVE {
x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap();
}
assert!(matches!(
x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap_err(),
ExportError::Bad(_)
));
x.put("u", "a.csv", "text/csv", b"x", NOW).unwrap();
// Lapsed ones don't count: making one sweeps them.
x.put("t", "a.csv", "text/csv", b"x", NOW + EXPORT_TTL)
.unwrap();
assert_eq!(
std::fs::read_dir(dir.path().join(EXPORTS_DIR))
.unwrap()
.count(),
2,
"every lapsed one swept (both tokens'): the new one kept, a record and its bytes"
);
for (name, mime) in [("../x.zip", "application/zip"), ("x.zip", "zip")] {
assert!(matches!(
x.put("u", name, mime, b"x", NOW).unwrap_err(),
ExportError::Bad(_)
));
}
}
#[cfg(unix)]
#[test]
fn its_folder_and_files_are_private_and_strays_go() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let x = Exports::open(dir.path(), NOW).unwrap();
let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap();
let folder = dir.path().join(EXPORTS_DIR);
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&folder), 0o700);
for ext in ["json", "bin"] {
assert_eq!(mode(&folder.join(format!("{}.{ext}", e.id))), 0o600);
}
// A file left half made, or with no record, is swept.
std::fs::write(folder.join("ex_x.bin.new"), b"half").unwrap();
std::fs::write(folder.join("stray"), b"x").unwrap();
let _ = Exports::open(dir.path(), NOW).unwrap();
let mut left: Vec<String> = std::fs::read_dir(&folder)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(left, [format!("{}.bin", e.id), format!("{}.json", e.id)]);
}
}
+3 -1
View File
@@ -23,17 +23,19 @@ pub mod auth;
pub mod blob;
pub mod chain;
pub mod error;
pub mod exports;
pub mod format;
pub mod journal;
pub mod meta;
pub mod ops;
pub mod passphrase;
pub mod reconcilable;
pub mod root;
pub mod saving;
pub mod schema;
pub mod snapshot;
pub mod store;
pub mod uploads;
pub mod viewer;
pub use blob::BlobStore;
pub use error::StoreError;
+307
View File
@@ -0,0 +1,307 @@
//! The viewer's side channel (plan 3.6): what the agent asks the viewer
//! to show (`books.show`), and whether a viewer has the books open. Not
//! bookkeeping, so never journaled, but kept with the books, in their own
//! `ops/` folder: the server writes; the viewer, which never writes, reads.
//!
//! ```text
//! GOAL: the agent can move a viewer that has the books open, and know
//! whether one has, without the viewer writing anything.
//! SOURCE: plan 3.6 ("the server appends {seq, ts, target} to
//! ops/show.jsonl (bounded, rotated, not journaled)"; "the GUI holds
//! a shared flock on ops/viewer.lock, opened read-only. The server
//! probes the lock to report viewer.open").
//! INVARIANTS:
//! 1. A show request is a line of `ops/show.jsonl`, `{seq, at, target}`,
//! its seq one more than the last; the file holds at most KEPT lines
//! and is replaced whole (written aside, renamed), so a reader sees
//! the old file or the new one, each line whole.
//! 2. `ops/` (0700) and `ops/viewer.lock` (0600) are made by the server
//! (`prepare`), never by the viewer; a request only navigates, so a
//! stray writer can do no more than move the view.
//! 3. A viewer is open while some process holds a shared lock on
//! `ops/viewer.lock`; the viewer takes it through a read-only
//! descriptor, and `viewer_open` probes it without taking it.
//! ```
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::{Path, PathBuf};
/// The books' side-channel folder, in their own folder.
pub const OPS_DIR: &str = "ops";
/// The show requests (INVARIANT 1).
pub const SHOW_FILE: &str = "show.jsonl";
/// The viewer's presence lock (INVARIANT 3).
pub const VIEWER_LOCK: &str = "viewer.lock";
/// The most show requests kept.
pub const KEPT: usize = 32;
/// Where to go.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "show", rename_all = "snake_case")]
pub enum ViewerTarget {
/// A top-level page: overview, cash, profit, transactions, reports,
/// accounts, assets, parties, documents, company, journal.
Page {
/// Page name.
page: String,
},
/// One entry, with its lines, receipt, and history.
Entry {
/// Entry id.
entry_id: u64,
},
/// An account's register, optionally for a date range.
Account {
/// Account code.
code: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
/// Last date, YYYY-MM-DD.
end: Option<String>,
},
/// A report, optionally for a date range.
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
/// Last date, YYYY-MM-DD.
end: Option<String>,
/// Split into columns: total, month, quarter, or year.
#[serde(default)]
columns: Option<String>,
/// Compare with: prior_period or prior_year.
#[serde(default)]
compare: Option<String>,
},
/// A stored receipt or document.
Document {
/// Attachment id.
attachment_id: u64,
},
/// One fixed asset: its identity, schedule, entries, and documents.
Asset {
/// Asset id.
asset_id: u64,
},
/// One party: who they are, their roles, and everything the company
/// did with them.
Party {
/// Party id.
party_id: u64,
},
/// One reconciliation: its account, with the account's statements and
/// reconciliations.
Reconciliation {
/// Reconciliation id.
id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.
query: String,
},
}
/// A request for the viewer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewerCommand {
/// Increases with every request.
pub seq: u64,
/// When it was made, RFC 3339.
pub at: String,
/// Where to go.
pub target: ViewerTarget,
}
/// The `ops/` folder of the books in `data_dir`.
pub fn ops_dir(data_dir: &Path) -> PathBuf {
data_dir.join(OPS_DIR)
}
/// Make the books' `ops/` folder and the viewer's lock (INVARIANT 2): the
/// server does, when it opens the books and before it writes a request.
pub fn prepare(data_dir: &Path) -> std::io::Result<()> {
let dir = crate::root::private_book_folder(data_dir, OPS_DIR)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let lock = dir.join(VIEWER_LOCK);
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
o.open(&lock)?;
// Not the books: kept out of a books dir's version history (an older
// books dir lives in git), by a rule in the folder itself.
let ignore = dir.join(".gitignore");
if !ignore.exists() {
std::fs::write(&ignore, "# The viewer's side channel: not the books.\n*\n")?;
}
Ok(())
}
/// Every request kept, oldest first; lines that aren't whole requests are
/// skipped.
pub fn requests(data_dir: &Path) -> Vec<ViewerCommand> {
std::fs::read_to_string(ops_dir(data_dir).join(SHOW_FILE))
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
/// The latest request for the books in `data_dir`, if any.
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
requests(data_dir).pop()
}
/// Ask the viewer of the books in `data_dir` to show `target`
/// (INVARIANT 1).
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
prepare(data_dir)?;
let mut kept = requests(data_dir);
let cmd = ViewerCommand {
seq: kept.last().map_or(1, |c| c.seq + 1),
at: chrono::Utc::now().to_rfc3339(),
target,
};
kept.push(cmd.clone());
let from = kept.len().saturating_sub(KEPT);
let mut text = String::new();
for c in &kept[from..] {
text.push_str(&serde_json::to_string(c).map_err(std::io::Error::other)?);
text.push('\n');
}
let dir = ops_dir(data_dir);
let aside = dir.join(format!(".{SHOW_FILE}.new"));
let _ = std::fs::remove_file(&aside);
let mut o = std::fs::OpenOptions::new();
o.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
let mut f = o.open(&aside)?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&aside, dir.join(SHOW_FILE))?;
Ok(cmd)
}
/// A viewer's presence: a shared lock on `ops/viewer.lock`, through a
/// read-only descriptor, held while this lives (INVARIANT 3).
#[derive(Debug)]
pub struct Presence(#[allow(dead_code)] std::fs::File);
impl Presence {
/// Take it for the books in `data_dir`, if the server made the lock
/// (None until it has: the viewer makes nothing).
pub fn hold(data_dir: &Path) -> Option<Presence> {
let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
// SAFETY: flock on a descriptor this function owns.
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 {
return None;
}
}
Some(Presence(f))
}
}
/// Whether a viewer has the books in `data_dir` open (INVARIANT 3): some
/// process holds a shared lock on their `ops/viewer.lock`.
pub fn viewer_open(data_dir: &Path) -> bool {
let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else {
return false;
};
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
// SAFETY: flock on a descriptor this function owns; a lock taken
// is let go at once (and with the descriptor anyway).
let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
if free {
// SAFETY: as above.
unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) };
}
!free
}
#[cfg(not(unix))]
{
drop(f);
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn requests_count_up_and_only_so_many_are_kept() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read(dir.path()), None);
let a = request(dir.path(), ViewerTarget::Entry { entry_id: 7 }).unwrap();
let b = request(
dir.path(),
ViewerTarget::Search {
query: "adobe".into(),
},
)
.unwrap();
assert_eq!((a.seq, b.seq), (1, 2));
assert_eq!(read(dir.path()), Some(b));
for n in 0..(KEPT as u64 + 5) {
request(dir.path(), ViewerTarget::Entry { entry_id: n }).unwrap();
}
let kept = requests(dir.path());
assert_eq!(kept.len(), KEPT);
assert_eq!(kept.last().unwrap().seq, 2 + KEPT as u64 + 5);
assert!(kept.windows(2).all(|w| w[1].seq == w[0].seq + 1));
// A torn line (an old writer's) is skipped, never the whole file.
let file = ops_dir(dir.path()).join(SHOW_FILE);
let mut text = std::fs::read_to_string(&file).unwrap();
text.push_str("{\"seq\": 99, \"at\"");
std::fs::write(&file, text).unwrap();
assert_eq!(requests(dir.path()).len(), KEPT);
}
#[cfg(unix)]
#[test]
fn the_server_makes_the_folder_and_the_viewer_only_reads_it() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
// Nothing made: no presence, and taking it makes nothing.
assert!(Presence::hold(dir.path()).is_none());
assert!(!ops_dir(dir.path()).exists());
assert!(!viewer_open(dir.path()));
prepare(dir.path()).unwrap();
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&ops_dir(dir.path())), 0o700);
assert_eq!(mode(&ops_dir(dir.path()).join(VIEWER_LOCK)), 0o600);
assert!(!viewer_open(dir.path()));
let held = Presence::hold(dir.path()).unwrap();
assert!(viewer_open(dir.path()));
// Two viewers at once share it.
let second = Presence::hold(dir.path()).unwrap();
drop(held);
assert!(viewer_open(dir.path()));
drop(second);
assert!(!viewer_open(dir.path()));
// Made again, nothing changes.
prepare(dir.path()).unwrap();
assert!(!viewer_open(dir.path()));
// Kept out of a books dir's version history by a rule of its own.
let rule = std::fs::read_to_string(ops_dir(dir.path()).join(".gitignore")).unwrap();
assert!(rule.lines().any(|l| l == "*"), "{rule}");
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Saving a copy of a file for the human (`wanderbooks download`, the
//! viewer's "Save a copy" and exports): always a new file, whole.
//!
//! ```text
//! GOAL: a copy saved for the human never replaces a file of theirs and
//! never leaves part of itself under the name.
//! INVARIANTS:
//! 1. The copy goes under `named`, or `named (1)`, `named (2)`... the
//! first free, else `fallback` (a folder that takes no such name),
//! never over a file.
//! 2. It is written to a hidden file in the same folder and linked in
//! whole; on a file system without links it is written in place,
//! new, and removed if it can't be finished. The hidden file is gone
//! whatever happens (a killed process excepted).
//! 3. A failure says why: the folder's own error, not "no free name".
//! ```
use std::path::PathBuf;
/// Save `body` in `folder` as `named`, or `named (1)`, `named (2)`... the
/// first free; `fallback` if the folder takes no such name. Written aside
/// (a hidden file) and linked in whole, so the name never holds part of
/// it; never over another file. The path it went to.
pub fn save_new(
folder: &std::path::Path,
named: &str,
fallback: &str,
body: &[u8],
) -> Result<PathBuf, String> {
use std::io::Write;
let fail = |what: &std::path::Path, e: std::io::Error| {
format!("can't write {}: {}", what.display(), e.kind())
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let aside = folder.join(format!(".wanderbooks-{}-{nanos}.part", std::process::id()));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&aside)
.map_err(|e| fail(folder, e))?;
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&aside);
return Err(fail(folder, e));
}
drop(f);
let candidates = |name: &str| {
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.to_string(), String::new()),
};
let name = name.to_string();
(0..1000).map(move |n| {
if n == 0 {
name.clone()
} else {
format!("{stem} ({n}){ext}")
}
})
};
let mut outcome = Err(format!("{} has no free name for it", folder.display()));
// Why the last name tried was refused, if not for being taken: what
// the human is told when no name is left (a full disk, not a name).
let mut last: Option<std::io::Error> = None;
'names: for name in [named, fallback] {
for candidate in candidates(name) {
let path = folder.join(&candidate);
match std::fs::hard_link(&aside, &path) {
Ok(()) => {
outcome = Ok(path);
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
// A file system with no links (FAT): written in place, never
// over another file, removed if it can't be finished.
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied
) =>
{
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&path);
outcome = Err(fail(&path, e));
last = None;
} else {
outcome = Ok(path);
}
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
// A name this folder won't take (too long, a character it
// refuses): the next name.
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
}
let _ = std::fs::remove_file(&aside);
match (outcome, last) {
(Err(_), Some(e)) => Err(fail(folder, e)),
(outcome, _) => outcome,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_saved_copy_is_new_and_whole_beside_what_is_there() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("r.pdf"), b"theirs").unwrap();
let first = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(first, d.join("r (1).pdf"));
let second = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(second, d.join("r (2).pdf"));
assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"theirs");
assert_eq!(std::fs::read(&first).unwrap(), b"ours");
// A name the folder won't take: the fallback.
let long = "x".repeat(300);
assert_eq!(
save_new(d, &long, "attachment-7", b"ours").unwrap(),
d.join("attachment-7")
);
// Nothing aside is left.
let mut names: Vec<String> = std::fs::read_dir(d)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, ["attachment-7", "r (1).pdf", "r (2).pdf", "r.pdf"]);
// A folder that can't be written: refused, nothing made.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let ro = d.join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(ro.join("probe"), b"").is_err() {
assert!(save_new(&ro, "r.pdf", "attachment-7", b"ours").is_err());
}
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap();
}
}
}
-232
View File
@@ -1,232 +0,0 @@
//! Telling a running viewer what to show.
//!
//! The agent can ask the viewer (`ledger-gui`) to open an entry, an
//! account, a report, a document, or a search. That's not bookkeeping, so
//! it never touches the books: the request is a small JSON file outside the
//! data directory, in the user's runtime directory, named for the books it
//! belongs to. The viewer watches it and navigates; the server writes it.
//! A request carries a sequence number so the same one never applies
//! twice, and a time so a viewer started later ignores stale ones.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Where to go.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "show", rename_all = "snake_case")]
pub enum ViewerTarget {
/// A top-level page: overview, cash, profit, transactions, reports,
/// accounts, assets, parties, documents, company, journal.
Page {
/// Page name.
page: String,
},
/// One entry, with its lines, receipt, and history.
Entry {
/// Entry id.
entry_id: u64,
},
/// An account's register, optionally for a date range.
Account {
/// Account code.
code: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
/// Last date, YYYY-MM-DD.
end: Option<String>,
},
/// A report, optionally for a date range.
Report {
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
/// trial_balance, general_ledger, tax_summary, vendor_1099,
/// ar_aging, ap_aging, fixed_asset_register.
report: String,
/// First date, YYYY-MM-DD.
start: Option<String>,
/// Last date, YYYY-MM-DD.
end: Option<String>,
/// Split into columns: total, month, quarter, or year.
#[serde(default)]
columns: Option<String>,
/// Compare with: prior_period or prior_year.
#[serde(default)]
compare: Option<String>,
},
/// A stored receipt or document.
Document {
/// Attachment id.
attachment_id: u64,
},
/// One fixed asset: its identity, schedule, entries, and documents.
Asset {
/// Asset id.
asset_id: u64,
},
/// One party: who they are, their roles, and everything the company
/// did with them.
Party {
/// Party id.
party_id: u64,
},
/// One reconciliation: its account, with the account's statements and
/// reconciliations.
Reconciliation {
/// Reconciliation id.
id: u64,
},
/// The transactions list, searched.
Search {
/// Search text: description, account, vendor, amount.
query: String,
},
}
/// A request for the viewer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewerCommand {
/// Increases with every request.
pub seq: u64,
/// When it was made, RFC 3339.
pub at: String,
/// Where to go.
pub target: ViewerTarget,
}
/// The command file for the books in `data_dir`: in `$XDG_RUNTIME_DIR`
/// (or the temp dir), under `wanderbooks/`, named by a hash of the data
/// directory's canonical path.
pub fn command_path(data_dir: &Path) -> PathBuf {
let canonical = data_dir
.canonicalize()
.unwrap_or_else(|_| data_dir.to_path_buf());
let key = crate::journal::sha256_hex(canonical.to_string_lossy().as_bytes());
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join("wanderbooks")
.join(format!("viewer-{}.json", &key[..16]))
}
/// Ask the viewer for `data_dir` to show `target`. Atomic (write then
/// rename), so the viewer never reads half a request.
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
let path = command_path(data_dir);
if let Some(dir) = path.parent() {
private_folder(dir)?;
}
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
let cmd = ViewerCommand {
seq,
at: chrono::Utc::now().to_rfc3339(),
target,
};
let tmp = path.with_extension("json.tmp");
std::fs::write(
&tmp,
serde_json::to_vec(&cmd).map_err(std::io::Error::other)?,
)?;
std::fs::rename(&tmp, &path)?;
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()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn requests_round_trip_and_count_up() {
let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let _cleanup = TestDir(dir.clone());
let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap();
let b = request(
&dir,
ViewerTarget::Search {
query: "adobe".into(),
},
)
.unwrap();
assert_eq!(b.seq, a.seq + 1);
assert_eq!(
read(&dir).unwrap().target,
ViewerTarget::Search {
query: "adobe".into()
}
);
// 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));
}
}