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)]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user