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:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -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)]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user