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
@@ -19,6 +19,9 @@ ledger-store = { path = "../ledger-store" }
|
||||
ledger-import = { path = "../ledger-import" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
# The CPA package, the 1099 worksheet, and the reports they share with
|
||||
# the viewer.
|
||||
ledger-package = { path = "../ledger-package" }
|
||||
base64 = "0.22"
|
||||
# A download's Repr-Digest (local.rs).
|
||||
sha2 = "0.10"
|
||||
@@ -37,7 +40,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io
|
||||
tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] }
|
||||
# A call that panics is recorded before the panic goes on (access.rs).
|
||||
futures-util = "0.3"
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
# The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped
|
||||
# from memory when dropped, as the vault's own are.
|
||||
@@ -52,6 +54,8 @@ libc = "0.2"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# The tests open the CPA package.
|
||||
zip = "9.0.1"
|
||||
tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
jsonschema = { version = "0.58", default-features = false }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 1099s
|
||||
|
||||
- Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically).
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault: hand it over (save_csv_to, a new file outside the books) and never repeat those numbers anywhere else.
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true it carries the full TINs from the vault, for the CPA's copy; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
- Card payments are reported by the card processor (1099-K) and are left out.
|
||||
@@ -1,5 +1,5 @@
|
||||
# CPA handoff
|
||||
|
||||
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet), so it needs save_to: a new .zip or a folder outside the books.
|
||||
- Tell the human where it was saved; never open, read, attach, or copy the file, and never echo its TINs.
|
||||
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package.
|
||||
- Never file the package, or a file from it, into the books.
|
||||
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
|
||||
@@ -4,5 +4,5 @@
|
||||
- an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
|
||||
- inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
|
||||
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
|
||||
- Files holding full TINs (books.get_w9, books.vendor_1099 with include_tins, books.export_cpa_package with TINs) are never saved inside the books. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
|
||||
- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder (books served by `wanderbooks mcp`; elsewhere it comes back inline). They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
|
||||
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`).
|
||||
@@ -7,5 +7,5 @@
|
||||
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
|
||||
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
|
||||
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN).
|
||||
- Never repeat a TIN back to the human, in chat, a description, note, file name, version-history message, or anywhere else; tools show the last four only. books.get_w9 writes the form out for the human (never open or read it yourself); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
|
||||
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
|
||||
- books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
@@ -194,6 +194,12 @@ pub fn run(
|
||||
if let Some(why) = &report.healed {
|
||||
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||
}
|
||||
// The viewer's side channel, made as the books open (ledger_store::ops
|
||||
// INVARIANT 2), so a viewer already open shows its presence before
|
||||
// the first books.show. Not the books: a failure only says so.
|
||||
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
|
||||
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
|
||||
}
|
||||
// Books that keep their own settings never read a settings file, not
|
||||
// even to start (settings INVARIANT 1); others read it as before.
|
||||
let mut live = if store.ledger().settings().is_journaled() {
|
||||
|
||||
+125
-47
@@ -44,16 +44,19 @@
|
||||
//! dropped. Tokens are made, listed and revoked only through
|
||||
//! the admin socket.
|
||||
//! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go
|
||||
//! out only by `GET /b/{id}/a/{attachment}` (plan 4.7), each through
|
||||
//! the same door as 8 and counted for the close like any request:
|
||||
//! an upload only from the token that began it, a download only with
|
||||
//! books:read. Each, refused at the door or not, is recorded in the
|
||||
//! book's access log as `upload` or `download` with its id, and the
|
||||
//! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET
|
||||
//! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each
|
||||
//! through the same door as 8 and counted for the close like any
|
||||
//! request: an upload only from the token that began it, a download
|
||||
//! only with books:read, an export only to the token it was made for
|
||||
//! (whose tool passed its own scope check; no other is asked). Each,
|
||||
//! refused at the door or not, is recorded in the book's access log
|
||||
//! as `upload`, `download` or `export` with its id, and the
|
||||
//! bytes (kept, or read) and their record are one task that runs to
|
||||
//! its end if the client hangs up (a process killed in that task
|
||||
//! excepted); bytes whose record can't be written aren't kept, and
|
||||
//! the books stop. Lapsed uploads are swept
|
||||
//! every SWEEP_EVERY. Nothing comes in once the books stopped.
|
||||
//! the books stop. Lapsed uploads and exports are swept every
|
||||
//! SWEEP_EVERY. Nothing comes in or goes out once the books stopped.
|
||||
//! ```
|
||||
|
||||
use crate::admin::{self, Request, Response};
|
||||
@@ -108,8 +111,8 @@ type Service = StreamableHttpService<BooksServer, LocalSessionManager>;
|
||||
enum Book {
|
||||
/// Open and served.
|
||||
Open {
|
||||
/// Its MCP service.
|
||||
service: Service,
|
||||
/// Its MCP service (boxed: the other variant is a line of text).
|
||||
service: Box<Service>,
|
||||
/// Its store, closed when the server closes (INVARIANT 6).
|
||||
store: Arc<SharedStore>,
|
||||
/// Its access log, where requests refused at the door are
|
||||
@@ -117,6 +120,8 @@ enum Book {
|
||||
access: Arc<AccessLog>,
|
||||
/// Its files on their way in (`/b/{id}/u/{upload}`).
|
||||
uploads: ledger_store::uploads::Uploads,
|
||||
/// The files it made for agents (`/b/{id}/x/{export}`).
|
||||
exports: ledger_store::exports::Exports,
|
||||
},
|
||||
/// Not opened, with why (INVARIANT 3).
|
||||
Failed(String),
|
||||
@@ -271,6 +276,13 @@ impl Books {
|
||||
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
|
||||
.map_err(|e| self.hide(e.to_string()))?;
|
||||
let server_uploads = uploads.clone();
|
||||
let exports =
|
||||
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
|
||||
.map_err(|e| self.hide(e.to_string()))?;
|
||||
let server_exports = exports.clone();
|
||||
// The viewer's side channel, made by the server (ops.rs
|
||||
// INVARIANT 2): a viewer can show its presence from the start.
|
||||
ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?;
|
||||
let live = Arc::new(Mutex::new(live));
|
||||
let store = SharedStore::new(store);
|
||||
let log = access.clone();
|
||||
@@ -288,17 +300,19 @@ impl Books {
|
||||
.with_live_settings(live.clone())
|
||||
.with_layout(layout.clone())
|
||||
.with_access(log.clone())
|
||||
.with_uploads(server_uploads.clone()),
|
||||
.with_uploads(server_uploads.clone())
|
||||
.with_exports(server_exports.clone()),
|
||||
)
|
||||
},
|
||||
Arc::new(sessions()),
|
||||
config,
|
||||
);
|
||||
Ok(Book::Open {
|
||||
service,
|
||||
service: Box::new(service),
|
||||
store: shared,
|
||||
access,
|
||||
uploads,
|
||||
exports,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1158,6 +1172,36 @@ async fn download(
|
||||
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
||||
axum::extract::Path((book, attachment)): axum::extract::Path<(String, String)>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
fetch(books, book, Wanted::Attachment(attachment), request).await
|
||||
}
|
||||
|
||||
/// `GET /b/{book}/x/{export}`: a file the books made for an agent
|
||||
/// (exports.rs; D-29), only to the token it was made for (INVARIANT 9);
|
||||
/// recorded in the access log (action `export`).
|
||||
async fn export(
|
||||
axum::extract::State(books): axum::extract::State<Arc<Books>>,
|
||||
axum::extract::Path((book, id)): axum::extract::Path<(String, String)>,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
fetch(books, book, Wanted::Export(id), request).await
|
||||
}
|
||||
|
||||
/// What a GET fetches.
|
||||
enum Wanted {
|
||||
/// A stored receipt or document, by its id.
|
||||
Attachment(String),
|
||||
/// A file the books made, by its id.
|
||||
Export(String),
|
||||
}
|
||||
|
||||
/// A download or an export: the door, the read and its record, the
|
||||
/// headers (INVARIANT 9).
|
||||
async fn fetch(
|
||||
books: Arc<Books>,
|
||||
book: String,
|
||||
wanted: Wanted,
|
||||
request: axum::extract::Request,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
@@ -1175,58 +1219,86 @@ async fn download(
|
||||
books
|
||||
.last_arrival
|
||||
.store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let (action, subject) = match &wanted {
|
||||
Wanted::Attachment(id) => ("download", id.clone()),
|
||||
Wanted::Export(id) => ("export", id.clone()),
|
||||
};
|
||||
let asked = Asked {
|
||||
action: "download",
|
||||
subject: attachment.clone(),
|
||||
action,
|
||||
subject: subject.clone(),
|
||||
};
|
||||
let (held, token) = match door(&books, &book, &asked, request.headers()).await {
|
||||
Ok(passed) => passed,
|
||||
Err(refused) => return *refused,
|
||||
};
|
||||
let Book::Open { access, store, .. } = &held else {
|
||||
let Book::Open {
|
||||
access,
|
||||
store,
|
||||
exports,
|
||||
..
|
||||
} = &held
|
||||
else {
|
||||
unreachable!("the door lets only open books through")
|
||||
};
|
||||
// The read and its record are one blocking task, run to its end even
|
||||
// if the client hangs up, and counted until then.
|
||||
let (access, store_for) = (access.clone(), store.clone());
|
||||
let (access, store_for, exports) = (access.clone(), store.clone(), exports.clone());
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
let _counted = _counted;
|
||||
// An id as the books write it (no sign, no leading zeros), so the
|
||||
// log holds no alias of it.
|
||||
let id = attachment
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|n| n.to_string() == attachment);
|
||||
let found = match (token.scopes.contains(&Scope::Read), id) {
|
||||
use ledger_store::exports::ExportError;
|
||||
let not_found = |code, what: &str| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
code,
|
||||
format!("there is no such {what}"),
|
||||
)
|
||||
};
|
||||
let found = if store_for.is_stopped() {
|
||||
// Stopped after an internal error: as for uploads.
|
||||
_ if store_for.is_stopped() => Err((
|
||||
Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"stopped",
|
||||
crate::server::STOPPED.to_string(),
|
||||
)),
|
||||
(false, _) => Err((
|
||||
))
|
||||
// An export is bound to the token it was made for, whose tool
|
||||
// passed its own scope check; a stored file needs books:read.
|
||||
} else if matches!(wanted, Wanted::Attachment(_)) && !token.scopes.contains(&Scope::Read) {
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"insufficient_scope",
|
||||
format!(
|
||||
"this agent's token can't do that: a download needs books:read, and its token ({:?}) hasn't it",
|
||||
token.name
|
||||
),
|
||||
)),
|
||||
(true, None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"no_such_attachment",
|
||||
"there is no such attachment".to_string(),
|
||||
)),
|
||||
(true, Some(id)) => match store_for.attachment_file(id) {
|
||||
Ok(Some(file)) => Ok(file),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
"no_such_attachment",
|
||||
"there is no such attachment".to_string(),
|
||||
)),
|
||||
// Stopped, or the stored file damaged: not a missing one.
|
||||
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
|
||||
},
|
||||
))
|
||||
} else {
|
||||
match &wanted {
|
||||
Wanted::Export(id) => {
|
||||
match exports.get(id, &token.id, chrono::Utc::now().timestamp()) {
|
||||
Ok((x, bytes)) => Ok((x.file_name, x.mime, bytes)),
|
||||
Err(e @ ExportError::Unknown(_)) => {
|
||||
Err((StatusCode::NOT_FOUND, "no_such_export", e.to_string()))
|
||||
}
|
||||
Err(e @ ExportError::NotYours) => {
|
||||
Err((StatusCode::FORBIDDEN, "not_yours", e.to_string()))
|
||||
}
|
||||
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", e.to_string())),
|
||||
}
|
||||
}
|
||||
// An id as the books write it (no sign, no leading zeros),
|
||||
// so the log holds no alias of it.
|
||||
Wanted::Attachment(a) => {
|
||||
match a.parse::<u64>().ok().filter(|n| n.to_string() == *a) {
|
||||
None => Err(not_found("no_such_attachment", "attachment")),
|
||||
Some(id) => match store_for.attachment_file(id) {
|
||||
Ok(Some(file)) => Ok(file),
|
||||
Ok(None) => Err(not_found("no_such_attachment", "attachment")),
|
||||
// The stored file damaged: not a missing one.
|
||||
Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let record = Access {
|
||||
principal: Principal {
|
||||
@@ -1236,8 +1308,8 @@ async fn download(
|
||||
},
|
||||
client: None,
|
||||
request_id: None,
|
||||
action: "download".into(),
|
||||
subject: attachment.clone(),
|
||||
action: action.into(),
|
||||
subject,
|
||||
args_digest: None,
|
||||
result: match &found {
|
||||
Ok(_) => Outcome::Ok,
|
||||
@@ -1346,7 +1418,7 @@ async fn mcp(
|
||||
let Book::Open { service, .. } = &held else {
|
||||
unreachable!("the door lets only open books through")
|
||||
};
|
||||
let service = service.clone();
|
||||
let service = (**service).clone();
|
||||
let principal = |t: &Token| Principal {
|
||||
id: t.id.clone(),
|
||||
kind: "agent".into(),
|
||||
@@ -1690,17 +1762,22 @@ async fn serve(
|
||||
tokio::select! {
|
||||
() = sweep_stop.cancelled() => break,
|
||||
_ = tick.tick() => {
|
||||
let uploads: Vec<_> = sweeping
|
||||
let held: Vec<_> = sweeping
|
||||
.held()
|
||||
.values()
|
||||
.filter_map(|b| match b {
|
||||
Book::Open { uploads, .. } => Some(uploads.clone()),
|
||||
Book::Open { uploads, exports, .. } => {
|
||||
Some((uploads.clone(), exports.clone()))
|
||||
}
|
||||
Book::Failed(_) => None,
|
||||
})
|
||||
.collect();
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
uploads.iter().for_each(|u| u.sweep(now));
|
||||
for (u, x) in &held {
|
||||
u.sweep(now);
|
||||
x.sweep(now);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1712,6 +1789,7 @@ async fn serve(
|
||||
.route("/b/{book}/mcp", axum::routing::any(mcp))
|
||||
.route("/b/{book}/u/{upload}", axum::routing::put(upload))
|
||||
.route("/b/{book}/a/{attachment}", axum::routing::get(download))
|
||||
.route("/b/{book}/x/{export}", axum::routing::get(export))
|
||||
.fallback(not_found)
|
||||
.with_state(books.clone());
|
||||
// The HTTP side keeps answering (refusing, once closing) until the
|
||||
|
||||
+182
-1206
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,10 @@
|
||||
//! refusal leaves it for the right one.
|
||||
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
|
||||
//! before it is decoded.
|
||||
//! 6. A file the books make for an agent (D-29) is handed over as an
|
||||
//! export the caller's token fetches with `wanderbooks download`
|
||||
//! (books served from the data root), or over stdio inline; never
|
||||
//! by a path.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
@@ -114,7 +118,83 @@ pub struct BeginUploadOut {
|
||||
pub existing_attachment_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// `name` made a name the checks take: control and invisible characters
|
||||
/// and separators dropped or made `_`, cut to fit; `file` if nothing is
|
||||
/// left.
|
||||
fn plain_file_name(name: &str) -> String {
|
||||
let mut out: String = name
|
||||
.chars()
|
||||
.filter(|c| !ledger_store::uploads::is_hidden_char(*c))
|
||||
.map(|c| {
|
||||
if c.is_control() || c == '/' || c == '\\' {
|
||||
'_'
|
||||
} else {
|
||||
c
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
while out.len() > ledger_store::uploads::MAX_NAME_BYTES {
|
||||
out.pop();
|
||||
}
|
||||
let out = out.trim().to_string();
|
||||
if ledger_store::uploads::file_name_problem(&out).is_none() {
|
||||
out
|
||||
} else {
|
||||
"file".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export
|
||||
/// for the caller's token, or inline over stdio.
|
||||
pub(crate) fn hand_over(
|
||||
&self,
|
||||
book: &str,
|
||||
file_name: &str,
|
||||
mime: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<HandedFile, String> {
|
||||
// A name kept before names were checked (a W-9 sealed earlier) is
|
||||
// made plain rather than refused: the file is the point.
|
||||
let file_name = match ledger_store::uploads::file_name_problem(file_name) {
|
||||
None => file_name.to_string(),
|
||||
Some(_) => plain_file_name(file_name),
|
||||
};
|
||||
let mut out = HandedFile {
|
||||
file_name: file_name.clone(),
|
||||
mime_type: mime.to_string(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
sha256: ledger_store::journal::sha256_hex(&bytes),
|
||||
download: None,
|
||||
expires_at: None,
|
||||
content_base64: None,
|
||||
};
|
||||
let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else {
|
||||
out.content_base64 = Some(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bytes,
|
||||
));
|
||||
return Ok(out);
|
||||
};
|
||||
let kept = exports
|
||||
.put(
|
||||
&token,
|
||||
&file_name,
|
||||
mime,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
out.download = Some(format!(
|
||||
"wanderbooks download --book {book}{} {} --to <a folder>",
|
||||
token_flag(),
|
||||
kept.id
|
||||
));
|
||||
out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
|
||||
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The token of the call running on this thread (its id), if any.
|
||||
fn caller_token(&self) -> Option<String> {
|
||||
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
|
||||
@@ -264,3 +344,37 @@ impl BooksServer {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plain_file_name;
|
||||
|
||||
/// A name kept before names were checked is handed over cleaned, never
|
||||
/// refused (a W-9 sealed earlier, say).
|
||||
#[test]
|
||||
fn an_old_name_is_made_plain() {
|
||||
assert_eq!(
|
||||
plain_file_name("W9 \u{2714}\u{FE0F}.pdf"),
|
||||
"W9 \u{2714}.pdf"
|
||||
);
|
||||
assert_eq!(plain_file_name("W-9\u{200E}.pdf"), "W-9.pdf");
|
||||
assert_eq!(plain_file_name("a/b\\c\n.pdf"), "a_b_c_.pdf");
|
||||
assert_eq!(plain_file_name("\u{200B}"), "file");
|
||||
assert_eq!(plain_file_name(".."), "file");
|
||||
let long = "x".repeat(400);
|
||||
let cut = plain_file_name(&long);
|
||||
assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES);
|
||||
for n in [
|
||||
"W9 \u{2714}\u{FE0F}.pdf",
|
||||
"a/b\\c\n.pdf",
|
||||
&long,
|
||||
"\u{202E}fdp.exe",
|
||||
] {
|
||||
assert_eq!(
|
||||
ledger_store::uploads::file_name_problem(&plain_file_name(n)),
|
||||
None,
|
||||
"{n:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,15 +362,14 @@ pub struct Vendor1099Params {
|
||||
/// through 2025, $2,000 from 2026).
|
||||
pub threshold: Option<String>,
|
||||
/// True: each line carries its payee's full TIN, read from the TIN
|
||||
/// vault (or a note saying why not). The output then holds full TINs:
|
||||
/// for the CPA only, when the human asks for it. Default false (last
|
||||
/// four digits only).
|
||||
/// vault (or a note saying why not). Default false (last four digits
|
||||
/// only).
|
||||
#[serde(default)]
|
||||
pub include_tins: bool,
|
||||
/// Also write the worksheet as CSV to this local path (TINs included
|
||||
/// only with include_tins). Never inside the books dir, and with TINs
|
||||
/// an existing file is refused.
|
||||
pub save_csv_to: Option<String>,
|
||||
/// True: also hand the worksheet over as a CSV file (TINs included
|
||||
/// with include_tins), to save with its download command.
|
||||
#[serde(default)]
|
||||
pub csv: bool,
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
@@ -415,11 +414,10 @@ pub struct Vendor1099Out {
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Caveats for the human and CPA.
|
||||
pub notes: Vec<String>,
|
||||
/// With include_tins: this output holds full TINs. For the CPA only;
|
||||
/// never paste it anywhere else.
|
||||
/// With include_tins: this output holds full TINs, for the CPA.
|
||||
pub warning: Option<String>,
|
||||
/// The CSV written with save_csv_to, if any.
|
||||
pub saved_csv: Option<String>,
|
||||
/// With csv: the worksheet as a CSV file, handed over.
|
||||
pub csv: Option<HandedFile>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.verify`.
|
||||
@@ -492,6 +490,8 @@ pub struct ShowParams {
|
||||
pub struct ShowOut {
|
||||
/// What the viewer was asked to show.
|
||||
pub shown: String,
|
||||
/// Whether a viewer has these books open now.
|
||||
pub viewer_open: bool,
|
||||
/// Where this goes and what to tell the human.
|
||||
pub note: String,
|
||||
}
|
||||
@@ -2604,28 +2604,16 @@ pub struct ExportCpaPackageParams {
|
||||
pub include_receipts: bool,
|
||||
/// Carry every member's and 1099 payee's full TIN from the TIN vault
|
||||
/// in the K-1 and 1099 worksheets (default true: the CPA needs them).
|
||||
/// Then `save_to` is required, outside the books dir, and the package
|
||||
/// never comes back in the output. False: last fours only, and the
|
||||
/// zip comes back as base64 unless `save_to` is given.
|
||||
/// False: last fours only.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_tins: bool,
|
||||
/// Where to write the package: a new `.zip` file, or an existing
|
||||
/// folder (the zip goes in it as `cpa-package-<as_of>.zip`). Written
|
||||
/// readable by its owner only. With TINs, never inside the books dir.
|
||||
pub save_to: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of `books.export_cpa_package`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ExportCpaPackageOut {
|
||||
/// File name, e.g. "cpa-package-2026-10-11.zip".
|
||||
pub file_name: String,
|
||||
/// The zip, base64-encoded: only when it isn't saved (`save_to`
|
||||
/// absent, which needs include_tins false).
|
||||
pub content_base64: Option<String>,
|
||||
/// The path the zip was written to, when saved. Tell the human; never
|
||||
/// open it, attach it, or copy it into the books.
|
||||
pub saved_to: Option<String>,
|
||||
/// The zip: run its `download` to save it.
|
||||
pub file: HandedFile,
|
||||
/// Whether the worksheets carry full TINs from the vault.
|
||||
pub tins_included: bool,
|
||||
/// With TINs: each member or 1099 payee whose TIN the package lacks,
|
||||
@@ -2822,24 +2810,39 @@ pub struct TinOut {
|
||||
pub struct GetW9Params {
|
||||
/// The party: id or name.
|
||||
pub party: String,
|
||||
/// Where to write the W-9 file: a new local file outside the books
|
||||
/// (the file shows the full TIN). An existing file is refused.
|
||||
pub save_to: String,
|
||||
}
|
||||
|
||||
/// Result of `books.get_w9`: where the file went, never its contents.
|
||||
/// A file the books made for an agent, handed over (tools/files.rs
|
||||
/// INVARIANT 6; D-29).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct HandedFile {
|
||||
/// Its name, e.g. "cpa-package-2026-10-11.zip".
|
||||
pub file_name: String,
|
||||
/// Its MIME type.
|
||||
pub mime_type: String,
|
||||
/// Its size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// Its SHA-256, hex.
|
||||
pub sha256: String,
|
||||
/// The command that saves it in a folder you name (run it; books
|
||||
/// served by `wanderbooks mcp`). Kept 15 minutes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub download: Option<String>,
|
||||
/// When it lapses (UTC), with `download`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
/// Over stdio: its bytes, base64.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_base64: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.get_w9`: the W-9 file, handed over.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct GetW9Out {
|
||||
/// The party.
|
||||
pub party_id: u64,
|
||||
/// The path written.
|
||||
pub path: String,
|
||||
/// The file's original name.
|
||||
pub file_name: String,
|
||||
/// Its size in bytes.
|
||||
pub size_bytes: u64,
|
||||
/// What to tell the human.
|
||||
pub note: String,
|
||||
/// The file: run its `download` to save it.
|
||||
pub file: HandedFile,
|
||||
}
|
||||
|
||||
/// Parameters for `books.purge_tin`.
|
||||
|
||||
@@ -798,20 +798,17 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
}
|
||||
|
||||
/// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with
|
||||
/// include_tins, books.export_cpa_package with TINs) are never written
|
||||
/// inside the books, and wherever they are written (a downloads folder
|
||||
/// too), each is refused by its bytes when given back to be filed: a
|
||||
/// TIN-bearing file can't be filed back in as a receipt or document and
|
||||
/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
/// include_tins, books.export_cpa_package with TINs) come back as files
|
||||
/// handed over (D-29: no tool takes a path), and each is refused by its
|
||||
/// bytes when given back to be filed: a TIN-bearing file can't be filed
|
||||
/// back in as a receipt or document and read out through
|
||||
/// books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
#[test]
|
||||
fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
let dir = test_dir("tin-files");
|
||||
let books = dir.join("books");
|
||||
let downloads = dir.join("downloads");
|
||||
let config = dir.join("config");
|
||||
for d in [&downloads, &config] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
|
||||
let mut mcp = Mcp::spawn_env(
|
||||
&books.join("books-data"),
|
||||
@@ -833,78 +830,44 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
);
|
||||
// Inside the books: refused, and nothing written there.
|
||||
let before = std::fs::read_dir(&books).unwrap().count();
|
||||
let in_books = |name: &str| books.join(name).to_str().unwrap().to_string();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": in_books("copy.pdf")}),
|
||||
),
|
||||
(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(why.contains("inside the books"), "{tool}: {why}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&books).unwrap().count(),
|
||||
before,
|
||||
"wrote into the books"
|
||||
);
|
||||
// Written to a downloads folder, each is refused by its bytes, by
|
||||
// every way a file is filed.
|
||||
let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string();
|
||||
mcp.call(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}),
|
||||
);
|
||||
mcp.call(
|
||||
// Each comes back inline over stdio.
|
||||
let got = mcp.call("books.get_w9", json!({"party": "Acme"}));
|
||||
let w9_back = handed(&got["file"]);
|
||||
assert_eq!(w9_back, w9, "the W-9 file, as stored");
|
||||
let ws = mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
);
|
||||
mcp.call(
|
||||
assert_eq!(ws["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv");
|
||||
let pkg = mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}),
|
||||
json!({"cash_accounts": ["1000"]}),
|
||||
);
|
||||
let written: Vec<std::path::PathBuf> = std::fs::read_dir(&downloads)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
assert_eq!(written.len(), 3, "{written:?}");
|
||||
assert_eq!(pkg["tins_included"], true);
|
||||
let handed_over = [w9_back, handed(&ws["csv"]), handed(&pkg["file"])];
|
||||
// Given back, each is refused by its bytes, by every way a file is
|
||||
// filed.
|
||||
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for path in &written {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
for (n, bytes) in handed_over.iter().enumerate() {
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
"content_base64": base64_like(bytes),
|
||||
"sha256": sha(bytes)}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "statement.csv",
|
||||
"mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes),
|
||||
"sha256": sha(&bytes)}),
|
||||
"content_base64": base64_like(bytes),
|
||||
"sha256": sha(bytes)}),
|
||||
),
|
||||
] {
|
||||
let why = mcp.call_err(tool, args);
|
||||
assert!(
|
||||
why.contains("wrote with full TINs"),
|
||||
"{tool} {}: {why}",
|
||||
path.display()
|
||||
"{tool} file {n}: {why}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -913,10 +876,16 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() {
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
// Without TINs, the 1099 worksheet may go anywhere a file may.
|
||||
// Without TINs, the 1099 worksheet is an ordinary file: it may be
|
||||
// filed like any other.
|
||||
let plain = mcp.call("books.vendor_1099", json!({"year": 2026, "csv": true}));
|
||||
assert!(plain["warning"].is_null(), "{plain}");
|
||||
assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
|
||||
let bytes = handed(&plain["csv"]);
|
||||
mcp.call(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}),
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes), "sha256": sha(&bytes)}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1185,6 +1154,21 @@ fn base64_like(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): over stdio
|
||||
/// they come inline, with no download command, and match the size and
|
||||
/// SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_import_export_over_stdio() {
|
||||
let dir = test_dir("import_export");
|
||||
@@ -1306,7 +1290,10 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pkg["file"]["file_name"], "cpa-package-2026-10-31.zip");
|
||||
assert_eq!(pkg["file"]["mime_type"], "application/zip");
|
||||
assert_eq!(pkg["tins_included"], false);
|
||||
let zip_bytes = handed(&pkg["file"]);
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
||||
let mut names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
@@ -1358,11 +1345,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let light = zip::ZipArchive::new(std::io::Cursor::new(
|
||||
b64.decode(light["content_base64"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let light = zip::ZipArchive::new(std::io::Cursor::new(handed(&light["file"]))).unwrap();
|
||||
assert_eq!(light.len(), rest.len());
|
||||
}
|
||||
|
||||
@@ -2040,19 +2023,28 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
assert_eq!(r["threshold"], "2100.00");
|
||||
let vendors = mcp.call("books.list_vendors", json!({}));
|
||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||
// Show the entry in the viewer: a request outside the books.
|
||||
// The server made the books' ops/ as they opened: a viewer open from
|
||||
// before the first books.show is seen by it.
|
||||
let viewer = ledger_store::ops::Presence::hold(&dir).expect("ops/ made at start");
|
||||
// Show the entry in the viewer: a request in the books' ops/, never
|
||||
// journaled.
|
||||
let shown = mcp.call("books.show", json!({"entry_id": id}));
|
||||
assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}")));
|
||||
let cmd = ledger_store::viewer::read(&dir).expect("request written");
|
||||
assert_eq!(shown["viewer_open"], true);
|
||||
let cmd = ledger_store::ops::read(&dir).expect("request written");
|
||||
assert_eq!(
|
||||
cmd.target,
|
||||
ledger_store::viewer::ViewerTarget::Entry { entry_id: id }
|
||||
ledger_store::ops::ViewerTarget::Entry { entry_id: id }
|
||||
);
|
||||
// With no viewer, the note says to open one.
|
||||
drop(viewer);
|
||||
let shown = mcp.call("books.show", json!({"page": "cash"}));
|
||||
assert_eq!(shown["viewer_open"], false);
|
||||
assert!(shown["note"].as_str().unwrap().contains("No viewer"));
|
||||
let err = mcp.call_err("books.show", json!({"entry_id": 9999}));
|
||||
assert!(err.contains("unknown entry"), "{err}");
|
||||
let err = mcp.call_err("books.show", json!({"page": "cash", "search": "x"}));
|
||||
assert!(err.contains("exactly one"), "{err}");
|
||||
let _ = std::fs::remove_file(ledger_store::viewer::command_path(&dir));
|
||||
// The books check out end to end, receipt included.
|
||||
let v = mcp.call("books.verify", json!({}));
|
||||
assert_eq!(v["status"], "ok");
|
||||
|
||||
@@ -16,7 +16,7 @@ use ledger_core::{
|
||||
Money, NewAsset, Side, depreciation_schedule, reports,
|
||||
};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::viewer::{ViewerCommand, ViewerTarget};
|
||||
use ledger_store::ops::{ViewerCommand, ViewerTarget};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -235,17 +235,15 @@ impl Mcp {
|
||||
out["content"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// The viewer command the last `books.show` wrote.
|
||||
/// The viewer command the last `books.show` wrote, in the books' own
|
||||
/// `ops/` (nothing in the runtime folder).
|
||||
fn viewer_command(&self) -> ViewerCommand {
|
||||
let dir = self.run.join("wanderbooks");
|
||||
let files: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "json"))
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "one viewer command file: {files:?}");
|
||||
serde_json::from_slice(&std::fs::read(&files[0]).unwrap()).unwrap()
|
||||
let books = self.run.parent().unwrap();
|
||||
assert!(
|
||||
!self.run.join("wanderbooks").exists(),
|
||||
"nothing written in the runtime folder"
|
||||
);
|
||||
ledger_store::ops::read(&data_dir(books)).expect("a show request")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +263,18 @@ fn sha256(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): over stdio
|
||||
/// they come inline, with no download command, and match the size and
|
||||
/// SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = b64d(file["content_base64"].as_str().expect("content_base64"));
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha256(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
fn d(s: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
@@ -2835,8 +2845,9 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
assert_eq!(out["file_name"], format!("cpa-package-{END}.zip"));
|
||||
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
||||
assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip"));
|
||||
assert_eq!(out["file"]["mime_type"], "application/zip");
|
||||
let files = unzip(&handed(&out["file"]));
|
||||
let names: Vec<&str> = files.keys().map(String::as_str).collect();
|
||||
|
||||
let mut want = vec![
|
||||
@@ -2944,7 +2955,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let files = unzip(&b64d(out["content_base64"].as_str().unwrap()));
|
||||
let files = unzip(&handed(&out["file"]));
|
||||
assert!(
|
||||
!files
|
||||
.keys()
|
||||
|
||||
@@ -1305,9 +1305,14 @@ fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
// The zip comes back inline over stdio, as stated (D-29).
|
||||
let file = &pkg["file"];
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(pkg["content_base64"].as_str().unwrap())
|
||||
.decode(file["content_base64"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
@@ -1394,13 +1399,12 @@ fn show_opens_a_reconciliation() {
|
||||
.unwrap();
|
||||
let out = m.read_only("books.show", json!({"reconciliation": rid}));
|
||||
assert!(out["shown"].as_str().unwrap().contains("1000"), "{out}");
|
||||
let run = m.data.parent().unwrap().join("run/wanderbooks");
|
||||
let cmd: Value = std::fs::read_dir(&run)
|
||||
.unwrap()
|
||||
.filter_map(|e| std::fs::read(e.unwrap().path()).ok())
|
||||
.find_map(|b| serde_json::from_slice(&b).ok())
|
||||
.expect("a viewer request");
|
||||
assert_eq!(cmd["target"], json!({"show": "reconciliation", "id": rid}));
|
||||
// The request is in the books' own ops/ (ledger_store::ops).
|
||||
let cmd = ledger_store::ops::read(&m.data).expect("a viewer request");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&cmd.target).unwrap(),
|
||||
json!({"show": "reconciliation", "id": rid})
|
||||
);
|
||||
m.refuse("books.show", json!({"reconciliation": 999}));
|
||||
m.refuse(
|
||||
"books.show",
|
||||
|
||||
@@ -252,9 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
// books.show writes a viewer request here; keep it out of the
|
||||
// user's real runtime dir.
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
|
||||
// the user's real one (the happy path names its own anyway).
|
||||
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
|
||||
@@ -334,6 +331,11 @@ impl Mcp {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
let name = p.file_name().unwrap().to_string_lossy().into_owned();
|
||||
// The viewer's side channel (books.show's requests) is not
|
||||
// the books: never journaled (ledger_store::ops).
|
||||
if p.is_dir() && name == ledger_store::ops::OPS_DIR {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
walk(&p, out);
|
||||
} else if name.ends_with("-wal") || name.ends_with("-shm") {
|
||||
@@ -812,11 +814,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"tin": "79-9714321", "tin_type": "ein",
|
||||
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
|
||||
);
|
||||
m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": "Landlord",
|
||||
"save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}),
|
||||
);
|
||||
m.ok("books.get_w9", json!({"party": "Landlord"}));
|
||||
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
|
||||
|
||||
// Owner contribution: draft, lines, receipt, preview, post.
|
||||
@@ -1234,10 +1232,10 @@ fn happy_path(name: &str) -> Mcp {
|
||||
m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
|
||||
m.ok("books.vendor_1099", json!({"year": 2026}));
|
||||
m.ok("books.vendor_1099", json!({"year": 2026, "csv": true}));
|
||||
m.ok(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true,
|
||||
"save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
);
|
||||
m.ok(
|
||||
"books.purge_tin",
|
||||
@@ -1279,8 +1277,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
);
|
||||
m.ok(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "include_receipts": false,
|
||||
"save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "include_receipts": false}),
|
||||
);
|
||||
|
||||
m.books_dir = Some(dir);
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! SQLite projection, the viewer, or any tool output, except the 1099
|
||||
//! worksheet for the CPA when asked (`books.vendor_1099` with
|
||||
//! `include_tins`); and the vault holds everyone's, members included, for
|
||||
//! the CPA package, which carries them only in a zip saved outside the
|
||||
//! books dir (DECISION, owner, 2026-10-15: "In the vault, that's the
|
||||
//! the CPA package, which carries them only in the zip it hands over,
|
||||
//! never kept in the books dir (DECISION, owner, 2026-10-15: "In the vault, that's the
|
||||
//! WHOLE reason we built it. And it needs to be in the damn CPA
|
||||
//! package."). The harness is `tests/spec_parties.rs`'s, with a
|
||||
//! transcript of every response and the server's stderr kept, and the vault
|
||||
@@ -444,8 +444,8 @@ const MEMBER_SSN: &str = "241-40-4915";
|
||||
const MEMBER_SSN_DIGITS: &str = "241404915";
|
||||
|
||||
/// Every file in a zip, by name.
|
||||
fn unzip(path: &Path) -> HashMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
|
||||
fn unzip(bytes: &[u8]) -> HashMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = HashMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
@@ -456,6 +456,61 @@ fn unzip(path: &Path) -> HashMap<String, Vec<u8>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`, D-29): over
|
||||
/// stdio they come inline, with no download command, and match the size
|
||||
/// and SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine as _;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// `v` with every file's inline bytes (`content_base64`) taken out, each
|
||||
/// pushed to `files` decoded, with its `file_name`: what a tool said, apart
|
||||
/// from the files it handed over.
|
||||
fn take_files(v: &Value, files: &mut Vec<(String, Vec<u8>)>) -> Value {
|
||||
use base64::Engine as _;
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if let Some(b) = o.get("content_base64").and_then(Value::as_str) {
|
||||
let name = o["file_name"].as_str().unwrap_or_default().to_string();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b)
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
files.push((name, bytes));
|
||||
}
|
||||
Value::Object(
|
||||
o.iter()
|
||||
.filter(|(k, _)| k.as_str() != "content_base64")
|
||||
.map(|(k, v)| (k.clone(), take_files(v, files)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(|v| take_files(v, files)).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` without the bytes of the files it hands over.
|
||||
fn said(v: &Value) -> String {
|
||||
take_files(v, &mut Vec::new()).to_string()
|
||||
}
|
||||
|
||||
/// The paths of every file under `dir` but the vault's: what a tool
|
||||
/// wrote, compared before and after.
|
||||
fn paths_outside_vault(m: &Mcp, dir: &Path) -> std::collections::BTreeSet<PathBuf> {
|
||||
files_under(dir)
|
||||
.into_iter()
|
||||
.filter(|f| !f.starts_with(m.vault()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A CSV's rows (header first) as maps from column name to cell, cells
|
||||
/// split on commas outside double quotes.
|
||||
fn csv_rows(bytes: &[u8]) -> Vec<HashMap<String, String>> {
|
||||
@@ -784,56 +839,25 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
docs["receipts"].as_array().unwrap().is_empty(),
|
||||
"not a document: {docs}"
|
||||
);
|
||||
// Read back to a path outside the books: the bytes, owner-only.
|
||||
let to = m.outside().join("copy.pdf");
|
||||
let out = m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}),
|
||||
// Read back: the bytes, handed over (inline over stdio), named for
|
||||
// their type; nothing written anywhere, and the output shows no TIN
|
||||
// but in the file itself.
|
||||
let (books_before, outside_before) = (
|
||||
paths_outside_vault(&m, &m.books_dir()),
|
||||
files_under(&m.outside()),
|
||||
);
|
||||
assert_eq!(out["path"], json!(to.display().to_string()));
|
||||
// Given inline, the file is named for its type.
|
||||
assert_eq!(out["file_name"], "w9.pdf");
|
||||
assert_eq!(std::fs::read(&to).unwrap(), src);
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&to).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
let books = m.books_dir();
|
||||
for save_to in [
|
||||
to.clone(), // exists
|
||||
books.join("w9.pdf"), // in the books dir
|
||||
m.data.join("w9.pdf"), // in the data dir
|
||||
books.join("vault/../w9.pdf"), // in it, by a detour
|
||||
m.outside().join("no/such/dir/w9.pdf"), // no folder
|
||||
] {
|
||||
m.refuse(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme Design", "save_to": save_to.to_str().unwrap()}),
|
||||
);
|
||||
}
|
||||
// Each refused as such, never as a failed write.
|
||||
for (save_to, said) in [
|
||||
(to.clone(), "a file is there already"),
|
||||
(
|
||||
m.outside().join("no/such/dir/w9.pdf"),
|
||||
"the folder for that path doesn't exist",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme Design", "save_to": save_to.to_str().unwrap()}),
|
||||
);
|
||||
assert!(why.contains(said), "{why}");
|
||||
}
|
||||
let out = m.ok("books.get_w9", json!({"party": "Acme Design"}));
|
||||
assert_eq!(out["party_id"], json!(acme));
|
||||
assert_eq!(out["file"]["file_name"], "w9.pdf");
|
||||
assert_eq!(out["file"]["mime_type"], "application/pdf");
|
||||
assert_eq!(handed(&out["file"]), src);
|
||||
assert!(!holds_tin(said(&out).as_bytes(), SSN_DIGITS), "{out}");
|
||||
assert_eq!(paths_outside_vault(&m, &m.books_dir()), books_before);
|
||||
assert_eq!(files_under(&m.outside()), outside_before);
|
||||
// A party with no W-9 file in the vault.
|
||||
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
|
||||
m.refuse(
|
||||
"books.get_w9",
|
||||
json!({"party": "Rent Co", "save_to": m.outside().join("rent.pdf").to_str().unwrap()}),
|
||||
);
|
||||
let why = m.refuse("books.get_w9", json!({"party": "Rent Co"}));
|
||||
assert!(why.contains("holds no W-9 file"), "{why}");
|
||||
// A W-9 file alone (the TIN typed in later) records the vault too.
|
||||
m.add_party("Solo", &["contractor"]);
|
||||
let solo = m.w9_file("000");
|
||||
@@ -870,13 +894,9 @@ fn every_w9_file_type_is_taken() {
|
||||
panic!("{name} refused")
|
||||
};
|
||||
assert_eq!(p["w9"]["in_vault"], true, "{name}");
|
||||
let to = m.outside().join(format!("copy-{n}"));
|
||||
let out = m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": party, "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], *name);
|
||||
assert_eq!(std::fs::read(&to).unwrap(), *bytes);
|
||||
let out = m.ok("books.get_w9", json!({"party": party}));
|
||||
assert_eq!(out["file"]["file_name"], *name);
|
||||
assert_eq!(handed(&out["file"]), *bytes);
|
||||
}
|
||||
// Its own name, when given; given alone, a name is refused.
|
||||
m.add_party("Payee N", &["contractor"]);
|
||||
@@ -888,12 +908,9 @@ fn every_w9_file_type_is_taken() {
|
||||
) else {
|
||||
panic!("named W-9 refused")
|
||||
};
|
||||
let to = m.outside().join("copy-named");
|
||||
let out = m.ok(
|
||||
"books.get_w9",
|
||||
json!({"party": "Payee N", "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(out["file_name"], "payee-n-w9.pdf");
|
||||
let out = m.ok("books.get_w9", json!({"party": "Payee N"}));
|
||||
assert_eq!(out["file"]["file_name"], "payee-n-w9.pdf");
|
||||
assert_eq!(handed(&out["file"]), bytes);
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee N",
|
||||
@@ -931,10 +948,7 @@ fn a_locked_vault_says_so_and_never_guesses() {
|
||||
m.pay("Acme Design", "700.00");
|
||||
let key = std::fs::read(m.key()).unwrap();
|
||||
std::fs::remove_file(m.key()).unwrap();
|
||||
let why = m.refuse(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme Design", "save_to": m.outside().join("x.pdf").to_str().unwrap()}),
|
||||
);
|
||||
let why = m.refuse("books.get_w9", json!({"party": "Acme Design"}));
|
||||
assert!(why.contains("locked"), "{why}");
|
||||
let v = m.read_only(
|
||||
"books.vendor_1099",
|
||||
@@ -1043,33 +1057,39 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
"{rent}"
|
||||
);
|
||||
assert_eq!(rent["tin_last4"], "9105");
|
||||
// The CSV goes to a new file outside the books.
|
||||
let csv = m.outside().join("1099-2026.csv");
|
||||
assert_eq!(
|
||||
full["warning"],
|
||||
"This worksheet holds full TINs, for the CPA; keep it out of the books."
|
||||
);
|
||||
// No CSV unless asked.
|
||||
assert!(plain["csv"].is_null() && full["csv"].is_null(), "{full}");
|
||||
// Asked, the CSV is handed over: with TINs, named for them.
|
||||
let out = m.read_only(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
);
|
||||
assert_eq!(out["saved_csv"], json!(csv.display().to_string()));
|
||||
let text = std::fs::read_to_string(&csv).unwrap();
|
||||
assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv");
|
||||
assert_eq!(out["csv"]["mime_type"], "text/csv");
|
||||
let text = String::from_utf8(handed(&out["csv"])).unwrap();
|
||||
assert!(text.contains(SSN), "{text}");
|
||||
assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin"));
|
||||
let without = m.outside().join("1099-plain.csv");
|
||||
m.read_only(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "save_csv_to": without.to_str().unwrap()}),
|
||||
let rows = csv_rows(text.as_bytes());
|
||||
let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], SSN);
|
||||
assert_eq!(acme["tin_type"], "ssn");
|
||||
let rent = rows.iter().find(|r| r["name"] == "Rent Co").unwrap();
|
||||
assert_eq!(rent["tin"], "");
|
||||
assert!(rent["tin_note"].contains("not in the vault"), "{rent:?}");
|
||||
// Without TINs: last fours only, and no TIN columns.
|
||||
let out = m.read_only("books.vendor_1099", json!({"year": 2026, "csv": true}));
|
||||
assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026.csv");
|
||||
let bytes = handed(&out["csv"]);
|
||||
assert!(!holds_tin(&bytes, SSN_DIGITS));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!holds_tin(&std::fs::read(&without).unwrap(), SSN_DIGITS));
|
||||
for to in [
|
||||
csv.clone(),
|
||||
m.books_dir().join("1099.csv"),
|
||||
m.data.join("1099.csv"),
|
||||
] {
|
||||
m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": to.to_str().unwrap()}),
|
||||
);
|
||||
}
|
||||
assert!(!m.books_dir().join("1099.csv").exists());
|
||||
}
|
||||
|
||||
// ------------------------------------------------- books.purge_tin
|
||||
@@ -1244,22 +1264,13 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
),
|
||||
(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": m.outside().join("cpa.zip").to_str().unwrap()}),
|
||||
),
|
||||
("books.party", json!({"party": "Mo"})),
|
||||
("books.parties", json!({"role": "member"})),
|
||||
(
|
||||
"books.purge_tin",
|
||||
json!({"party": "Rent Co", "reason": "preview only"}),
|
||||
),
|
||||
(
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme Design",
|
||||
"save_to": m.outside().join("for-cpa.pdf").to_str().unwrap()}),
|
||||
),
|
||||
("books.get_w9", json!({"party": "Acme Design"})),
|
||||
(
|
||||
"books.update_party",
|
||||
json!({"party": "Acme Design", "notes": "W-9 in the vault"}),
|
||||
@@ -1276,33 +1287,64 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
"the worksheet, asked"
|
||||
);
|
||||
assert!(holds_tin(full.to_string().as_bytes(), EIN_DIGITS));
|
||||
let csv = m.outside().join("w.csv");
|
||||
m.read_only(
|
||||
let csv = m.read_only(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
);
|
||||
// Every response but the worksheet's with include_tins.
|
||||
let leaks: Vec<&(String, String)> = m
|
||||
.transcript
|
||||
.iter()
|
||||
.filter(|(tool, resp)| {
|
||||
let worksheet =
|
||||
tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet");
|
||||
!worksheet
|
||||
&& (holds_tin(resp.as_bytes(), SSN_DIGITS)
|
||||
|| holds_tin(resp.as_bytes(), EIN_DIGITS)
|
||||
|| holds_tin(resp.as_bytes(), MEMBER_SSN_DIGITS))
|
||||
})
|
||||
.collect();
|
||||
let csv = handed(&csv["csv"]);
|
||||
assert!(holds_tin(&csv, SSN_DIGITS) && holds_tin(&csv, EIN_DIGITS));
|
||||
// The package, TINs included (the default).
|
||||
let pkg = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert_eq!(pkg["tins_included"], true);
|
||||
let pkg = unzip(&handed(&pkg["file"]));
|
||||
let holds_any = |b: &[u8]| {
|
||||
holds_tin(b, SSN_DIGITS) || holds_tin(b, EIN_DIGITS) || holds_tin(b, MEMBER_SSN_DIGITS)
|
||||
};
|
||||
// Every response but the worksheet's with include_tins, and every
|
||||
// file handed over but those that carry TINs for the CPA by design
|
||||
// (D-29): the W-9 file, the worksheet CSV with TINs, the package with
|
||||
// them (a zip's files read out of it).
|
||||
let (mut leaks, mut scanned): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
|
||||
for (tool, resp) in &m.transcript {
|
||||
let mut files = Vec::new();
|
||||
let rest = take_files(&serde_json::from_str(resp).unwrap(), &mut files).to_string();
|
||||
let worksheet =
|
||||
tool == "books.vendor_1099" && rest.contains("\"warning\":\"This worksheet");
|
||||
if !worksheet && holds_any(rest.as_bytes()) {
|
||||
leaks.push(format!("{tool}: {rest}"));
|
||||
}
|
||||
for (name, bytes) in files {
|
||||
let by_design = match tool.as_str() {
|
||||
"books.get_w9" => name == "w9.pdf",
|
||||
"books.vendor_1099" => name == "1099-worksheet-2026-with-tins.csv",
|
||||
"books.export_cpa_package" => rest.contains("\"tins_included\":true"),
|
||||
_ => false,
|
||||
};
|
||||
let inside = if name.ends_with(".zip") {
|
||||
unzip(&bytes).into_values().any(|b| holds_any(&b))
|
||||
} else {
|
||||
holds_any(&bytes)
|
||||
};
|
||||
if inside && !by_design {
|
||||
leaks.push(format!("{tool}: the file {name}"));
|
||||
}
|
||||
scanned.push(name);
|
||||
}
|
||||
}
|
||||
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
|
||||
// The handed-over files were scanned: two packages (without TINs,
|
||||
// with them), the W-9 file, the worksheet CSV.
|
||||
assert_eq!(scanned.len(), 4, "{scanned:?}");
|
||||
assert!(m.transcript.len() > 40, "the session used the books");
|
||||
// The server's log.
|
||||
let log = std::fs::read(m.outside().join("stderr.log")).unwrap();
|
||||
assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS));
|
||||
assert!(!holds_tin(&log, MEMBER_SSN_DIGITS));
|
||||
// The package saved outside the books carries them all: the member's
|
||||
// in the K-1 worksheet, the payees' in the 1099 worksheet.
|
||||
let pkg = unzip(&m.outside().join("cpa.zip"));
|
||||
// The package carries them all: the member's in the K-1 worksheet,
|
||||
// the payees' in the 1099 worksheet.
|
||||
assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS));
|
||||
let w1099 = &pkg["1099-worksheet-2026.csv"];
|
||||
assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS));
|
||||
@@ -1653,25 +1695,22 @@ fn k1_books(name: &str) -> Mcp {
|
||||
|
||||
/// The package carries the K-1 worksheet (one row per member: party,
|
||||
/// share, capital account, TIN, and the hand-worked capital figures) and
|
||||
/// the 1099 worksheet with full TINs, saved outside the books only; a
|
||||
/// missing TIN is a gap, never a failure.
|
||||
/// the 1099 worksheet with full TINs, handed over and never kept in the
|
||||
/// books; a missing TIN is a gap, never a failure.
|
||||
#[test]
|
||||
fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
let mut m = k1_books("k1");
|
||||
let to = m.outside().join("cpa-2026.zip");
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": to.to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert_eq!(out["saved_to"], json!(to.display().to_string()));
|
||||
assert!(out["content_base64"].is_null(), "never the bytes: {out}");
|
||||
assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip");
|
||||
assert_eq!(out["file"]["mime_type"], "application/zip");
|
||||
assert_eq!(out["tins_included"], true);
|
||||
let note = out["note"].as_str().unwrap();
|
||||
assert!(
|
||||
out["note"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(&to.display().to_string())
|
||||
note.contains("full TINs") && note.contains("1 TIN(s) are missing (tin_gaps)"),
|
||||
"{note}"
|
||||
);
|
||||
let gaps: Vec<&str> = out["tin_gaps"]
|
||||
.as_array()
|
||||
@@ -1681,15 +1720,9 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
.collect();
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert!(gaps[0].contains("Mo Example") && gaps[0].contains("TIN not in the vault"));
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(&to).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "the package is the owner's only");
|
||||
}
|
||||
let pkg = unzip(&to);
|
||||
assert!(!holds_tin(said(&out).as_bytes(), MEMBER_SSN_DIGITS));
|
||||
assert!(!holds_tin(said(&out).as_bytes(), EIN_DIGITS));
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
assert_eq!(k1.len(), 3, "two members and their total");
|
||||
let (tobin, mo) = (&k1[0], &k1[1]);
|
||||
@@ -1794,38 +1827,15 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display());
|
||||
}
|
||||
|
||||
// A folder: the zip goes in it, by its name.
|
||||
let folder = m.outside().join("for-casey");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": folder.to_str().unwrap()}),
|
||||
);
|
||||
assert_eq!(
|
||||
out["saved_to"],
|
||||
json!(
|
||||
folder
|
||||
.join("cpa-package-2026-12-31.zip")
|
||||
.display()
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
|
||||
// Without TINs: last fours only, returned as base64.
|
||||
// Without TINs: last fours only.
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
assert_eq!(out["tins_included"], false);
|
||||
let zip = base64::Engine::decode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
out["content_base64"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let plain_path = m.outside().join("plain.zip");
|
||||
std::fs::write(&plain_path, &zip).unwrap();
|
||||
let plain = unzip(&plain_path);
|
||||
assert_eq!(out["note"], "The package, with last fours only.");
|
||||
assert!(out["tin_gaps"].as_array().unwrap().is_empty(), "{out}");
|
||||
let plain = unzip(&handed(&out["file"]));
|
||||
let k1 = csv_rows(&plain["k1-members.csv"]);
|
||||
assert_eq!(k1[0]["tin_last4"], "4915");
|
||||
assert!(!k1[0].contains_key("tin"));
|
||||
@@ -1840,87 +1850,32 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
}
|
||||
}
|
||||
|
||||
/// With TINs the package is never returned and never lands in the books:
|
||||
/// no save_to, a path in the books dir or its exports/, an existing file,
|
||||
/// or a path that is neither a folder nor a .zip is refused.
|
||||
/// The package, with TINs or without, is handed over (inline over stdio,
|
||||
/// D-29) and never written: no file appears in the books dir outside the
|
||||
/// vault, or anywhere else.
|
||||
#[test]
|
||||
fn a_package_with_tins_goes_only_outside_the_books() {
|
||||
let mut m = k1_books("k1-where");
|
||||
let why = m.refuse(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert!(why.contains("save_to"), "{why}");
|
||||
fn a_package_is_handed_over_and_never_written() {
|
||||
let mut m = k1_books("k1-handed");
|
||||
let books = m.books_dir();
|
||||
std::fs::create_dir_all(books.join("exports")).unwrap();
|
||||
let existing = m.outside().join("taken.zip");
|
||||
std::fs::write(&existing, b"x").unwrap();
|
||||
for to in [
|
||||
books.join("cpa.zip"),
|
||||
books.join("exports"),
|
||||
books.join("exports/cpa.zip"),
|
||||
m.data.join("cpa.zip"),
|
||||
books.join("vault/../cpa.zip"),
|
||||
existing.clone(),
|
||||
m.outside().join("cpa.txt"),
|
||||
m.outside().join("no/such/dir/cpa.zip"),
|
||||
] {
|
||||
m.refuse(
|
||||
let (books_before, outside_before) =
|
||||
(paths_outside_vault(&m, &books), files_under(&m.outside()));
|
||||
for tins in [true, false] {
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": to.to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": tins}),
|
||||
);
|
||||
assert_eq!(out["tins_included"], tins);
|
||||
assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip");
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
assert!(pkg.contains_key("README.txt"), "include_tins {tins}");
|
||||
assert_eq!(
|
||||
holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS),
|
||||
tins,
|
||||
"include_tins {tins}"
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&existing).unwrap(), b"x");
|
||||
assert!(
|
||||
std::fs::read_dir(books.join("exports"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none()
|
||||
);
|
||||
assert!(!books.join("cpa.zip").exists());
|
||||
}
|
||||
|
||||
/// A package goes to a new file in a folder that exists, with or without
|
||||
/// TINs: a missing folder and a taken file are each refused as such (not
|
||||
/// as a failed write); without TINs, a new .zip path or an existing folder
|
||||
/// takes it.
|
||||
#[test]
|
||||
fn a_package_goes_only_to_a_new_file_in_a_folder_that_exists() {
|
||||
let mut m = k1_books("k1-dest");
|
||||
let missing = m.outside().join("no/such/dir/cpa.zip");
|
||||
let taken = m.outside().join("taken.zip");
|
||||
std::fs::write(&taken, b"x").unwrap();
|
||||
for tins in [true, false] {
|
||||
for (to, said) in [
|
||||
(&missing, "the folder for that path doesn't exist"),
|
||||
(&taken, "a file is there already"),
|
||||
] {
|
||||
let why = m.refuse(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"include_tins": tins, "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert!(why.contains(said), "include_tins {tins}: {why}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&taken).unwrap(), b"x");
|
||||
let to = m.outside().join("plain.zip");
|
||||
m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"include_tins": false, "save_to": to.to_str().unwrap()}),
|
||||
);
|
||||
assert!(unzip(&to).contains_key("README.txt"));
|
||||
let folder = m.outside().join("packages");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"include_tins": false, "save_to": folder.to_str().unwrap()}),
|
||||
);
|
||||
let saved: Vec<_> = std::fs::read_dir(&folder).unwrap().collect();
|
||||
assert_eq!(saved.len(), 1, "one package in the folder");
|
||||
assert_eq!(paths_outside_vault(&m, &books), books_before);
|
||||
assert_eq!(files_under(&m.outside()), outside_before);
|
||||
}
|
||||
|
||||
/// A locked vault never fails the package: every TIN row says why, and
|
||||
@@ -1929,12 +1884,11 @@ fn a_package_goes_only_to_a_new_file_in_a_folder_that_exists() {
|
||||
fn a_locked_vault_still_builds_the_package() {
|
||||
let mut m = k1_books("k1-locked");
|
||||
std::fs::remove_file(m.key()).unwrap();
|
||||
let to = m.outside().join("cpa.zip");
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": to.to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert_eq!(out["tins_included"], true);
|
||||
let gaps = out["tin_gaps"].as_array().unwrap();
|
||||
assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}");
|
||||
assert!(
|
||||
@@ -1942,7 +1896,7 @@ fn a_locked_vault_still_builds_the_package() {
|
||||
"{gaps:?}"
|
||||
);
|
||||
assert!(!m.key().exists(), "reading never makes a key");
|
||||
let pkg = unzip(&to);
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
let members: Vec<_> = k1
|
||||
.iter()
|
||||
@@ -2049,15 +2003,13 @@ fn a_k1_row_never_carries_another_partys_tin() {
|
||||
"KE Consulting LLC",
|
||||
json!({"tin": "12-3456789", "tin_type": "ein"}),
|
||||
);
|
||||
let to = m.outside().join("cpa-lookalike.zip");
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": to.to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
let gaps = out["tin_gaps"].to_string();
|
||||
assert!(gaps.contains("member Mo Example"), "{gaps}");
|
||||
let pkg = unzip(&to);
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
let mo = k1.iter().find(|r| r["member"] == "Mo Example").unwrap();
|
||||
assert_eq!(mo["tin"], "", "{mo:?}");
|
||||
@@ -2077,13 +2029,11 @@ fn joint_filers_are_noted_in_the_package() {
|
||||
let s = m.read_only("books.settings", json!({}));
|
||||
assert_eq!(s["members"][0]["files_jointly_with"], "Mo Example");
|
||||
assert_eq!(s["members"][1]["files_jointly_with"], "Tobin Example");
|
||||
let to = m.outside().join("joint.zip");
|
||||
m.read_only(
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"],
|
||||
"save_to": to.to_str().unwrap()}),
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
let pkg = unzip(&to);
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
|
||||
let note = "Tobin Example and Mo Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).";
|
||||
assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}");
|
||||
|
||||
@@ -185,6 +185,24 @@ fn inline(bytes: &[u8]) -> (String, String) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): inline, as on
|
||||
/// books served from a --data-dir, matching the size and SHA-256 it
|
||||
/// states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes)),
|
||||
"{file}"
|
||||
);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Every way the file at `path` (named `name`) could enter the books is
|
||||
/// refused with `why`, and nothing is stored: the refusals, tool by tool.
|
||||
async fn refused_everywhere(
|
||||
@@ -281,28 +299,28 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
// A bank statement filed in the books: the package carries it, and it
|
||||
// is no TIN file (its routing numbers are not TINs).
|
||||
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
|
||||
// What the server writes with TINs, from the first session.
|
||||
// What the server hands over with TINs, from the first session, saved
|
||||
// outside the books as the human would save it.
|
||||
let copy = b.out.join("w9-copy.pdf");
|
||||
ok(
|
||||
&one,
|
||||
"books.get_w9",
|
||||
json!({"party": "Acme", "save_to": copy.to_str().unwrap()}),
|
||||
)
|
||||
.await;
|
||||
let got = ok(&one, "books.get_w9", json!({"party": "Acme"})).await;
|
||||
std::fs::write(©, handed(&got["file"])).unwrap();
|
||||
let csv = b.out.join("1099.csv");
|
||||
ok(
|
||||
let got = ok(
|
||||
&one,
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}),
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
)
|
||||
.await;
|
||||
std::fs::write(&csv, handed(&got["csv"])).unwrap();
|
||||
let zip = b.out.join("cpa.zip");
|
||||
ok(
|
||||
let got = ok(
|
||||
&one,
|
||||
"books.export_cpa_package",
|
||||
json!({"cash_accounts": ["1000"], "save_to": zip.to_str().unwrap(), "as_of": "2026-12-31"}),
|
||||
json!({"cash_accounts": ["1000"], "as_of": "2026-12-31"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(got["tins_included"], true, "{got}");
|
||||
std::fs::write(&zip, handed(&got["file"])).unwrap();
|
||||
let k1 = b.out.join("k1-members.csv");
|
||||
{
|
||||
let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
|
||||
|
||||
Reference in new issue
Block a user