Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
1175 lines
42 KiB
Rust
1175 lines
42 KiB
Rust
//! Files on their way in (plan 4.7, P2 slice 5): an agent asks for an
|
|
//! upload (`books.begin_upload`), sends the bytes on its own (`wanderbooks
|
|
//! upload`, which streams them to the server), then names the upload in
|
|
//! the tool that files them.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: a file of any size up to the limit reaches the books whole,
|
|
//! exactly as declared, without passing through the agent's context
|
|
//! or naming a path on the server.
|
|
//! SOURCE: plan 4.7 ("single-use, valid 15 minutes, bound to the
|
|
//! principal, sha and size, capped at 25 MiB by default, and
|
|
//! re-hashed before acceptance"), D-28 (the agent does it).
|
|
//! INVARIANTS:
|
|
//! 1. A grant is made for one token, one SHA-256 and one size, and
|
|
//! lasts UPLOAD_TTL; its bytes are received once, by that token
|
|
//! alone, and filed once: a call that takes them holds them (no other
|
|
//! call can) and spends them only when it files them; a call that
|
|
//! fails, or panics, gives them back for the next.
|
|
//! 2. Bytes are written aside (`<id>.part`, by one request at a time)
|
|
//! and kept (`<id>.bin`) only if they are exactly the size declared
|
|
//! and hash to the SHA-256 declared; anything else leaves nothing but
|
|
//! a refusal. Taken, they are hashed again.
|
|
//! 3. Grants and their bytes live in the book's `uploads/` folder (0700,
|
|
//! files 0600), so they survive a restart; lapsed ones are swept when
|
|
//! the books open, whenever a grant is made, and (by the server) every
|
|
//! 10 seconds, so none lingers; at open, bytes a call held when the
|
|
//! server stopped are given back.
|
|
//! 4. No error names a path.
|
|
//! 5. A token has at most MAX_LIVE grants under way, so what one agent
|
|
//! can keep on the disk is bounded.
|
|
//! ```
|
|
|
|
use crate::journal::sha256_hex;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// The uploads' folder, in a book's folder.
|
|
pub const UPLOADS_DIR: &str = "uploads";
|
|
/// The largest file taken (plan 4.7).
|
|
pub const MAX_UPLOAD: u64 = 25 * 1024 * 1024;
|
|
/// How long a grant lasts, seconds (plan 4.7: 15 minutes).
|
|
pub const UPLOAD_TTL: i64 = 15 * 60;
|
|
/// The most grants one token may have under way at once (INVARIANT 5).
|
|
pub const MAX_LIVE: usize = 16;
|
|
/// What an upload may be for: the tools that file it take only theirs.
|
|
pub const PURPOSES: [&str; 4] = ["receipt", "document", "import", "w9"];
|
|
|
|
/// A grant: what may be sent, by whom, until when (INVARIANT 1).
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Grant {
|
|
/// Its id: `up_` and 32 letters `a` to `p`.
|
|
pub id: String,
|
|
/// The token it was made for.
|
|
pub token: String,
|
|
/// The bytes' SHA-256, hex.
|
|
pub sha256: String,
|
|
/// Their size.
|
|
pub size: u64,
|
|
/// Their MIME type.
|
|
pub mime: String,
|
|
/// The file's name.
|
|
pub file_name: String,
|
|
/// What it is for: receipt, document, import (a CSV) or w9.
|
|
pub purpose: String,
|
|
/// When it lapses (Unix seconds).
|
|
pub expires: i64,
|
|
}
|
|
|
|
/// Bytes received and checked, ready to file.
|
|
#[derive(Debug)]
|
|
pub struct Received {
|
|
/// The grant they came under.
|
|
pub grant: Grant,
|
|
/// The bytes.
|
|
pub bytes: Vec<u8>,
|
|
/// The hold on them: spend it once they're filed (INVARIANT 1).
|
|
pub claim: Claim,
|
|
}
|
|
|
|
/// A call's hold on an upload's bytes (INVARIANT 1): [`Claim::spend`]
|
|
/// once they're filed; dropped unspent, they're given back.
|
|
#[derive(Debug)]
|
|
pub struct Claim {
|
|
uploads: Uploads,
|
|
id: String,
|
|
spent: bool,
|
|
}
|
|
|
|
impl Claim {
|
|
/// The bytes are filed: the upload is used up.
|
|
pub fn spend(mut self) {
|
|
self.spent = true;
|
|
self.uploads.forget(&self.id);
|
|
}
|
|
}
|
|
|
|
impl Drop for Claim {
|
|
fn drop(&mut self) {
|
|
if !self.spent {
|
|
let _held = self.uploads.locked();
|
|
let _ = std::fs::rename(
|
|
self.uploads.path(&self.id, TAKEN),
|
|
self.uploads.path(&self.id, "bin"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why an upload is refused. Never names a path (INVARIANT 4).
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum UploadError {
|
|
/// No such grant, or it lapsed.
|
|
#[error("there is no upload {0:?}, or it lapsed (an upload lasts 15 minutes): begin another")]
|
|
Unknown(String),
|
|
/// Another token's.
|
|
#[error("that upload was begun with another token")]
|
|
NotYours,
|
|
/// Its bytes came already.
|
|
#[error("that upload's bytes were sent already")]
|
|
AlreadySent,
|
|
/// Another request is sending its bytes, or another call is filing
|
|
/// them.
|
|
#[error("that upload is in use by another request: wait for it to finish")]
|
|
InUse,
|
|
/// Its bytes haven't come.
|
|
#[error(
|
|
"that upload's bytes haven't been sent yet: send them with the command books.begin_upload gave"
|
|
)]
|
|
NotSent,
|
|
/// Not the size declared.
|
|
#[error("the bytes sent were {got} long, not the {want} declared: nothing was kept")]
|
|
WrongSize {
|
|
/// Declared.
|
|
want: u64,
|
|
/// Sent.
|
|
got: u64,
|
|
},
|
|
/// More than the size declared.
|
|
#[error("more than the {want} bytes declared were sent: nothing was kept")]
|
|
TooMuch {
|
|
/// Declared.
|
|
want: u64,
|
|
},
|
|
/// Not the SHA-256 declared.
|
|
#[error("the bytes sent don't have the SHA-256 declared: nothing was kept")]
|
|
WrongHash,
|
|
/// Over the limit.
|
|
#[error("a file is at most {MAX_UPLOAD} bytes")]
|
|
TooLarge,
|
|
/// A request that can't make a grant.
|
|
#[error("{0}")]
|
|
Bad(String),
|
|
/// The disk refused.
|
|
#[error("the upload couldn't be kept: {0}")]
|
|
Io(std::io::ErrorKind),
|
|
}
|
|
|
|
impl From<std::io::Error> for UploadError {
|
|
fn from(e: std::io::Error) -> Self {
|
|
UploadError::Io(e.kind())
|
|
}
|
|
}
|
|
|
|
/// A book's uploads.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Uploads {
|
|
dir: PathBuf,
|
|
/// Held while grants are made or swept and while bytes change name
|
|
/// (INVARIANTS 1, 2, 5): one server serves a book, so this orders
|
|
/// every request to its uploads.
|
|
lock: std::sync::Arc<std::sync::Mutex<()>>,
|
|
}
|
|
|
|
/// The longest file name taken, in bytes (most file systems' limit).
|
|
pub const MAX_NAME_BYTES: usize = 255;
|
|
|
|
/// Characters that change how a name shows without showing themselves
|
|
/// (Unicode's format characters: bidirectional overrides, zero widths).
|
|
pub fn is_hidden_char(c: char) -> bool {
|
|
matches!(c,
|
|
// Format characters (Unicode's Cf).
|
|
'\u{00AD}' | '\u{0600}'..='\u{0605}' | '\u{061C}' | '\u{06DD}'
|
|
| '\u{070F}' | '\u{0890}' | '\u{0891}' | '\u{08E2}' | '\u{180E}'
|
|
| '\u{200B}'..='\u{200F}'
|
|
| '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}'
|
|
| '\u{2066}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}'
|
|
| '\u{110BD}' | '\u{110CD}' | '\u{13430}'..='\u{1343F}'
|
|
| '\u{1BCA0}'..='\u{1BCA3}' | '\u{1D173}'..='\u{1D17A}'
|
|
| '\u{E0001}' | '\u{E0020}'..='\u{E007F}'
|
|
// Line and paragraph separators (Zl, Zp).
|
|
| '\u{2028}' | '\u{2029}'
|
|
// Characters that show as nothing: the grapheme joiner, the Hangul
|
|
// fillers, the Khmer inherent vowels, the variation selectors.
|
|
| '\u{034F}' | '\u{115F}' | '\u{1160}' | '\u{17B4}' | '\u{17B5}'
|
|
| '\u{180B}'..='\u{180F}' | '\u{2800}' | '\u{3164}' | '\u{FFA0}'
|
|
| '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}')
|
|
}
|
|
|
|
/// Why `name` can't name a file filed in the books, if it can't: a plain
|
|
/// name of 1 to MAX_NAME_BYTES bytes, no folder in it, nothing invisible.
|
|
pub fn file_name_problem(name: &str) -> Option<String> {
|
|
if name.trim().is_empty() {
|
|
return Some("a file name is needed".into());
|
|
}
|
|
if name.len() > MAX_NAME_BYTES {
|
|
return Some(format!("a file name is at most {MAX_NAME_BYTES} bytes"));
|
|
}
|
|
if name.contains(['/', '\\']) || name == "." || name == ".." {
|
|
return Some("a file name is a name alone, with no folder in it".into());
|
|
}
|
|
if name.chars().any(|c| c.is_control() || is_hidden_char(c)) {
|
|
return Some("a file name has no control or invisible characters".into());
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Why `mime` can't be a file's type, if it can't: `type/subtype` (and
|
|
/// any parameters), visible ASCII, at most 127 bytes.
|
|
pub fn mime_problem(mime: &str) -> Option<String> {
|
|
let ok = (3..=127).contains(&mime.len())
|
|
&& mime.bytes().all(|b| b.is_ascii_graphic() || b == b' ')
|
|
&& mime
|
|
.split(';')
|
|
.next()
|
|
.and_then(|t| t.trim().split_once('/'))
|
|
.is_some_and(|(a, b)| !a.is_empty() && !b.is_empty());
|
|
(!ok).then(|| "a file's type is a MIME type such as application/pdf".to_string())
|
|
}
|
|
|
|
/// The bytes of an upload a call holds (INVARIANT 1).
|
|
const TAKEN: &str = "taken";
|
|
|
|
/// An upload's id: `up_` and 32 letters `a` to `p` (16 random bytes, a
|
|
/// letter a nibble). No digits, so the access log, which masks what could
|
|
/// be a TIN, keeps an id whole.
|
|
pub fn is_id(id: &str) -> bool {
|
|
id.len() == 3 + 32
|
|
&& id.starts_with("up_")
|
|
&& id[3..].bytes().all(|b| (b'a'..=b'p').contains(&b))
|
|
}
|
|
|
|
fn new_id(raw: &[u8; 16]) -> String {
|
|
let mut id = String::from("up_");
|
|
for b in raw {
|
|
id.push(char::from(b'a' + (b >> 4)));
|
|
id.push(char::from(b'a' + (b & 0x0f)));
|
|
}
|
|
id
|
|
}
|
|
|
|
fn private_file(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 Uploads {
|
|
/// The uploads of the book in `book_dir` (its folder made, private, if
|
|
/// missing), as the books open, before any call can run (INVARIANT 3):
|
|
/// lapsed grants swept, bytes a call held given back, and any half
|
|
/// sent thrown away.
|
|
pub fn open(book_dir: &Path, now: i64) -> Result<Self, UploadError> {
|
|
let dir = crate::root::private_book_folder(book_dir, UPLOADS_DIR)
|
|
.map_err(|e| UploadError::Bad(format!("the uploads can't be kept: {e}")))?;
|
|
let uploads = Self {
|
|
dir,
|
|
lock: Default::default(),
|
|
};
|
|
if let Ok(entries) = std::fs::read_dir(&uploads.dir) {
|
|
for e in entries.flatten() {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
if let Some(id) = name.strip_suffix(&format!(".{TAKEN}")) {
|
|
let _ = std::fs::rename(e.path(), uploads.path(id, "bin"));
|
|
} else if name.ends_with(".part") {
|
|
let _ = std::fs::remove_file(e.path());
|
|
}
|
|
}
|
|
}
|
|
uploads.sweep(now);
|
|
Ok(uploads)
|
|
}
|
|
|
|
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}"))
|
|
}
|
|
|
|
/// Make a grant for `token` (INVARIANT 1).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn grant(
|
|
&self,
|
|
token: &str,
|
|
sha256: &str,
|
|
size: u64,
|
|
mime: &str,
|
|
file_name: &str,
|
|
purpose: &str,
|
|
now: i64,
|
|
) -> Result<Grant, UploadError> {
|
|
let sha = sha256.trim().to_ascii_lowercase();
|
|
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
return Err(UploadError::Bad(
|
|
"sha256 is the file's SHA-256: 64 hex digits".into(),
|
|
));
|
|
}
|
|
if size == 0 {
|
|
return Err(UploadError::Bad("an empty file isn't filed".into()));
|
|
}
|
|
if let Some(why) = file_name_problem(file_name).or_else(|| mime_problem(mime)) {
|
|
return Err(UploadError::Bad(why));
|
|
}
|
|
if size > MAX_UPLOAD {
|
|
return Err(UploadError::TooLarge);
|
|
}
|
|
if !PURPOSES.contains(&purpose) {
|
|
return Err(UploadError::Bad(
|
|
"purpose is receipt, document, import (a bank or chart CSV) or w9".into(),
|
|
));
|
|
}
|
|
let _held = self.locked();
|
|
self.sweep_held(now);
|
|
// INVARIANT 5.
|
|
if self.live_for(token, now) >= MAX_LIVE {
|
|
return Err(UploadError::Bad(format!(
|
|
"this agent has {MAX_LIVE} uploads under way: file them, or let them lapse (15 minutes)"
|
|
)));
|
|
}
|
|
let mut raw = [0u8; 16];
|
|
getrandom::getrandom(&mut raw)
|
|
.map_err(|_| UploadError::Bad("the system gave no randomness".into()))?;
|
|
let grant = Grant {
|
|
id: new_id(&raw),
|
|
token: token.to_string(),
|
|
sha256: sha,
|
|
size,
|
|
mime: mime.to_string(),
|
|
file_name: file_name.to_string(),
|
|
purpose: purpose.to_string(),
|
|
expires: now + UPLOAD_TTL,
|
|
};
|
|
// Written aside and renamed, so a grant is read whole or not at all.
|
|
let aside = self.path(&grant.id, "json.new");
|
|
let mut f = private_file(&aside)?;
|
|
f.write_all(&serde_json::to_vec(&grant).expect("a grant serializes"))?;
|
|
f.sync_all()?;
|
|
drop(f);
|
|
std::fs::rename(&aside, self.path(&grant.id, "json"))?;
|
|
Ok(grant)
|
|
}
|
|
|
|
/// How many grants `token` has under way 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::<Grant>(&b).ok())
|
|
.filter(|g| g.token == token && g.expires > now)
|
|
.count()
|
|
}
|
|
|
|
/// The live grant `id`, for `token` (INVARIANT 1).
|
|
fn live(&self, id: &str, token: &str, now: i64) -> Result<Grant, UploadError> {
|
|
if !is_id(id) {
|
|
return Err(UploadError::Unknown(id.chars().take(40).collect()));
|
|
}
|
|
let unknown = || UploadError::Unknown(id.to_string());
|
|
let bytes = std::fs::read(self.path(id, "json")).map_err(|_| unknown())?;
|
|
let grant: Grant = serde_json::from_slice(&bytes).map_err(|_| unknown())?;
|
|
if grant.expires <= now {
|
|
self.forget(id);
|
|
return Err(unknown());
|
|
}
|
|
if grant.token != token {
|
|
return Err(UploadError::NotYours);
|
|
}
|
|
Ok(grant)
|
|
}
|
|
|
|
/// Receive grant `id`'s bytes from `body`, by `token` (INVARIANTS 1,
|
|
/// 2): kept only if exactly as declared.
|
|
pub fn receive(
|
|
&self,
|
|
id: &str,
|
|
token: &str,
|
|
body: &mut impl Read,
|
|
now: i64,
|
|
) -> Result<Grant, UploadError> {
|
|
let grant = self.live(id, token, now)?;
|
|
let started = std::time::Instant::now();
|
|
let done = self.path(id, "bin");
|
|
let part = self.path(id, "part");
|
|
// One request at a time (INVARIANT 2): no bytes kept or held, and
|
|
// no `.part` of another request.
|
|
let mut out = {
|
|
let _held = self.locked();
|
|
if done.exists() || self.path(id, TAKEN).exists() {
|
|
return Err(UploadError::AlreadySent);
|
|
}
|
|
match private_file(&part) {
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
return Err(UploadError::InUse);
|
|
}
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
};
|
|
let mut hasher = Sha256::new();
|
|
let mut got: u64 = 0;
|
|
let mut buf = vec![0u8; 64 * 1024];
|
|
// Read one byte past the size declared, to know when there's more.
|
|
let mut limited = body.take(grant.size + 1);
|
|
let read = loop {
|
|
let n = match limited.read(&mut buf) {
|
|
Ok(0) => break Ok(()),
|
|
Ok(n) => n,
|
|
Err(e) => break Err(UploadError::from(e)),
|
|
};
|
|
got += n as u64;
|
|
if got > grant.size {
|
|
break Ok(());
|
|
}
|
|
hasher.update(&buf[..n]);
|
|
if let Err(e) = out.write_all(&buf[..n]) {
|
|
break Err(e.into());
|
|
}
|
|
};
|
|
let checked = read.and_then(|()| {
|
|
if got > grant.size {
|
|
return Err(UploadError::TooMuch { want: grant.size });
|
|
}
|
|
if got != grant.size {
|
|
return Err(UploadError::WrongSize {
|
|
want: grant.size,
|
|
got,
|
|
});
|
|
}
|
|
if hex::encode(hasher.finalize()) != grant.sha256 {
|
|
return Err(UploadError::WrongHash);
|
|
}
|
|
out.sync_all()?;
|
|
Ok(())
|
|
});
|
|
drop(out);
|
|
match checked {
|
|
Ok(()) => {
|
|
// Kept only if its grant still stands (it may have lapsed,
|
|
// or been filed by another request's bytes, meanwhile).
|
|
let _held = self.locked();
|
|
let standing = std::fs::read(self.path(id, "json"))
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice::<Grant>(&b).ok())
|
|
// At the time the bytes finished coming, not began.
|
|
.is_some_and(|g| {
|
|
g == grant && g.expires > now + started.elapsed().as_secs() as i64
|
|
});
|
|
if !standing || self.path(id, TAKEN).exists() || done.exists() {
|
|
let _ = std::fs::remove_file(&part);
|
|
return Err(if standing {
|
|
UploadError::AlreadySent
|
|
} else {
|
|
UploadError::Unknown(id.to_string())
|
|
});
|
|
}
|
|
std::fs::rename(&part, &done)?;
|
|
Ok(grant)
|
|
}
|
|
Err(e) => {
|
|
let _ = std::fs::remove_file(&part);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Take grant `id`'s bytes, by `token` (INVARIANTS 1, 2): held for
|
|
/// this call alone, checked again against the SHA-256 declared, and
|
|
/// used up only when the claim is spent.
|
|
pub fn take(&self, id: &str, token: &str, now: i64) -> Result<Received, UploadError> {
|
|
let grant = self.live(id, token, now)?;
|
|
let taken = self.path(id, TAKEN);
|
|
// The rename is the hold: of two calls, one gets it.
|
|
{
|
|
let _held = self.locked();
|
|
if let Err(e) = std::fs::rename(self.path(id, "bin"), &taken) {
|
|
return Err(match e.kind() {
|
|
std::io::ErrorKind::NotFound if taken.exists() => UploadError::InUse,
|
|
std::io::ErrorKind::NotFound => UploadError::NotSent,
|
|
k => UploadError::Io(k),
|
|
});
|
|
}
|
|
}
|
|
let claim = Claim {
|
|
uploads: self.clone(),
|
|
id: id.to_string(),
|
|
spent: false,
|
|
};
|
|
let bytes = match std::fs::read(&taken) {
|
|
Ok(b) => b,
|
|
Err(e) => return Err(UploadError::Io(e.kind())),
|
|
};
|
|
if bytes.len() as u64 != grant.size || sha256_hex(&bytes) != grant.sha256 {
|
|
// Changed since it came: never filed, never given back.
|
|
claim.spend();
|
|
return Err(UploadError::WrongHash);
|
|
}
|
|
Ok(Received {
|
|
grant,
|
|
bytes,
|
|
claim,
|
|
})
|
|
}
|
|
|
|
/// Throw away the bytes received for grant `id` (their record couldn't
|
|
/// be written): the grant stands, its bytes are to be sent again.
|
|
pub fn discard(&self, id: &str) {
|
|
if is_id(id) {
|
|
let _held = self.locked();
|
|
let _ = std::fs::remove_file(self.path(id, "bin"));
|
|
}
|
|
}
|
|
|
|
/// Forget grant `id` and anything kept for it.
|
|
fn forget(&self, id: &str) {
|
|
let _held = self.locked();
|
|
for ext in ["json", "part", "bin", TAKEN] {
|
|
let _ = std::fs::remove_file(self.path(id, ext));
|
|
}
|
|
}
|
|
|
|
/// Sweep away every grant 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);
|
|
}
|
|
|
|
/// [`Self::sweep`], the lock held.
|
|
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::<Grant>(&b).ok())
|
|
.is_none_or(|g| g.expires <= now);
|
|
if lapsed {
|
|
let _ = std::fs::remove_file(e.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const NOW: i64 = 1_800_000_000;
|
|
|
|
fn grant_for(u: &Uploads, token: &str, bytes: &[u8]) -> Grant {
|
|
u.grant(
|
|
token,
|
|
&sha256_hex(bytes),
|
|
bytes.len() as u64,
|
|
"application/pdf",
|
|
"r.pdf",
|
|
"receipt",
|
|
NOW,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_sent_as_declared_are_taken_once() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 a receipt".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
assert!(is_id(&g.id));
|
|
assert!(!g.id[3..].bytes().any(|b| b.is_ascii_digit()), "{}", g.id);
|
|
assert_eq!(new_id(&[0x00; 16]), format!("up_{}", "a".repeat(32)));
|
|
assert_eq!(new_id(&[0xff; 16]), format!("up_{}", "p".repeat(32)));
|
|
assert!(!is_id(&format!("up_{}", "0".repeat(32))));
|
|
assert!(!is_id(&format!("up_{}", "q".repeat(32))));
|
|
assert_eq!(g.expires, NOW + UPLOAD_TTL);
|
|
assert_eq!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::NotSent
|
|
);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap_err(),
|
|
UploadError::AlreadySent
|
|
);
|
|
assert_eq!(
|
|
u.take(&g.id, "tok_b", NOW).unwrap_err(),
|
|
UploadError::NotYours
|
|
);
|
|
let r = u.take(&g.id, "tok_a", NOW + 1).unwrap();
|
|
assert_eq!(r.bytes, bytes);
|
|
assert_eq!(r.grant, g);
|
|
r.claim.spend();
|
|
assert!(matches!(
|
|
u.take(&g.id, "tok_a", NOW + 2).unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
assert_eq!(
|
|
std::fs::read_dir(dir.path().join(UPLOADS_DIR))
|
|
.unwrap()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn anything_but_the_bytes_declared_leaves_nothing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"exactly these".to_vec();
|
|
for (sent, want) in [
|
|
(b"exactly thes".to_vec(), "short"),
|
|
(b"exactly these!".to_vec(), "long"),
|
|
(b"exactly thosE".to_vec(), "hash"),
|
|
] {
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
let e = u.receive(&g.id, "tok_a", &mut &sent[..], NOW).unwrap_err();
|
|
match want {
|
|
"hash" => assert_eq!(e, UploadError::WrongHash),
|
|
"long" => assert_eq!(e, UploadError::TooMuch { want: 13 }),
|
|
_ => assert!(matches!(e, UploadError::WrongSize { .. }), "{e}"),
|
|
}
|
|
assert!(
|
|
!dir.path()
|
|
.join(UPLOADS_DIR)
|
|
.join(format!("{}.bin", g.id))
|
|
.exists()
|
|
);
|
|
assert!(
|
|
!dir.path()
|
|
.join(UPLOADS_DIR)
|
|
.join(format!("{}.part", g.id))
|
|
.exists()
|
|
);
|
|
assert_eq!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::NotSent
|
|
);
|
|
}
|
|
// Another token's grant takes no bytes.
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_b", &mut &bytes[..], NOW).unwrap_err(),
|
|
UploadError::NotYours
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_lapses_and_is_swept() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"late".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
let later = NOW + UPLOAD_TTL;
|
|
assert!(matches!(
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], later)
|
|
.unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
let g2 = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g2.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
std::fs::write(dir.path().join(UPLOADS_DIR).join("stray"), "x").unwrap();
|
|
u.sweep(NOW + UPLOAD_TTL - 1);
|
|
assert!(
|
|
dir.path()
|
|
.join(UPLOADS_DIR)
|
|
.join(format!("{}.bin", g2.id))
|
|
.exists()
|
|
);
|
|
assert!(!dir.path().join(UPLOADS_DIR).join("stray").exists());
|
|
u.sweep(later);
|
|
assert_eq!(
|
|
std::fs::read_dir(dir.path().join(UPLOADS_DIR))
|
|
.unwrap()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_needs_a_hash_a_size_and_a_purpose() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let sha = sha256_hex(b"x");
|
|
for (s, size, purpose) in [
|
|
("nothex", 1, "receipt"),
|
|
(&sha[..63], 1, "receipt"),
|
|
(&sha, 0, "receipt"),
|
|
(&sha, MAX_UPLOAD + 1, "receipt"),
|
|
(&sha, 1, "anything"),
|
|
] {
|
|
assert!(
|
|
u.grant("t", s, size, "text/plain", "x", purpose, NOW)
|
|
.is_err(),
|
|
"{s} {size} {purpose}"
|
|
);
|
|
}
|
|
assert!(
|
|
u.grant(
|
|
"t",
|
|
&sha.to_uppercase(),
|
|
MAX_UPLOAD,
|
|
"text/plain",
|
|
"x",
|
|
"import",
|
|
NOW
|
|
)
|
|
.is_ok()
|
|
);
|
|
// An id that isn't one is no path into the folder.
|
|
for bad in ["../../journal", "up_", "up_XYZ", ""] {
|
|
assert!(matches!(
|
|
u.take(bad, "t", NOW).unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_are_held_by_one_call_and_given_back_unless_filed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 held".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
// Held: no other call gets them, and they can't be sent again.
|
|
let first = u.take(&g.id, "tok_a", NOW).unwrap();
|
|
assert_eq!(u.take(&g.id, "tok_a", NOW).unwrap_err(), UploadError::InUse);
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap_err(),
|
|
UploadError::AlreadySent
|
|
);
|
|
// A call that failed gives them back.
|
|
drop(first);
|
|
let again = u.take(&g.id, "tok_a", NOW).unwrap();
|
|
assert_eq!(again.bytes, bytes);
|
|
// A call that panicked does too.
|
|
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
let _held = again;
|
|
panic!("the call failed");
|
|
}));
|
|
assert!(caught.is_err());
|
|
let filed = u.take(&g.id, "tok_a", NOW).unwrap();
|
|
filed.claim.spend();
|
|
assert!(matches!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
assert_eq!(
|
|
std::fs::read_dir(dir.path().join(UPLOADS_DIR))
|
|
.unwrap()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn opening_gives_back_what_a_stopped_server_held_and_drops_half_sends() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 kept".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
let held = u.take(&g.id, "tok_a", NOW).unwrap();
|
|
// The server stops mid-call: nothing runs the claim's drop.
|
|
std::mem::forget(held);
|
|
let half = grant_for(&u, "tok_a", &bytes);
|
|
let folder = dir.path().join(UPLOADS_DIR);
|
|
std::fs::write(folder.join(format!("{}.part", half.id)), b"%PDF").unwrap();
|
|
// While running, a half send blocks another.
|
|
assert_eq!(
|
|
u.receive(&half.id, "tok_a", &mut &bytes[..], NOW)
|
|
.unwrap_err(),
|
|
UploadError::InUse
|
|
);
|
|
// A grant that lapses by the next open is swept by it.
|
|
let lapsing = u
|
|
.grant(
|
|
"tok_a",
|
|
&sha256_hex(b"z"),
|
|
1,
|
|
"text/plain",
|
|
"z",
|
|
"receipt",
|
|
NOW + 1 - UPLOAD_TTL,
|
|
)
|
|
.unwrap();
|
|
assert!(folder.join(format!("{}.json", lapsing.id)).exists());
|
|
let u = Uploads::open(dir.path(), NOW + 1).unwrap();
|
|
assert!(!folder.join(format!("{}.json", lapsing.id)).exists());
|
|
assert!(!folder.join(format!("{}.part", half.id)).exists());
|
|
u.receive(&half.id, "tok_a", &mut &bytes[..], NOW + 1)
|
|
.unwrap();
|
|
let back = u.take(&g.id, "tok_a", NOW + 1).unwrap();
|
|
assert_eq!(back.bytes, bytes);
|
|
back.claim.spend();
|
|
}
|
|
|
|
/// A body that, half read, has the grant forgotten (filed by another
|
|
/// request's bytes, or swept) under it.
|
|
struct Forgetting<'a> {
|
|
bytes: &'a [u8],
|
|
then: Option<Box<dyn FnOnce() + 'a>>,
|
|
}
|
|
|
|
impl std::io::Read for Forgetting<'_> {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
if let Some(f) = self.then.take() {
|
|
f();
|
|
}
|
|
self.bytes.read(buf)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_are_kept_only_while_their_grant_stands() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 late".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
let folder = dir.path().join(UPLOADS_DIR);
|
|
let json = folder.join(format!("{}.json", g.id));
|
|
let mut body = Forgetting {
|
|
bytes: &bytes,
|
|
then: Some(Box::new(|| std::fs::remove_file(&json).unwrap())),
|
|
};
|
|
let e = u.receive(&g.id, "tok_a", &mut body, NOW).unwrap_err();
|
|
assert!(matches!(e, UploadError::Unknown(_)), "{e}");
|
|
assert_eq!(
|
|
std::fs::read_dir(&folder).unwrap().count(),
|
|
0,
|
|
"nothing kept"
|
|
);
|
|
// Bytes whose record couldn't be written are thrown away; the
|
|
// grant stands, for them to be sent again.
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
u.discard(&g.id);
|
|
assert_eq!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::NotSent
|
|
);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
u.discard("../../journal");
|
|
assert_eq!(u.take(&g.id, "tok_a", NOW).unwrap().bytes, bytes);
|
|
}
|
|
|
|
/// A body that takes its time.
|
|
struct Slow<'a>(&'a [u8], std::time::Duration);
|
|
|
|
impl std::io::Read for Slow<'_> {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
std::thread::sleep(std::mem::take(&mut self.1));
|
|
self.0.read(buf)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_that_finish_coming_after_their_grant_lapsed_are_not_kept() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 slow".to_vec();
|
|
// It lapses a second after the send begins; the send takes longer.
|
|
let g = u
|
|
.grant(
|
|
"tok_a",
|
|
&sha256_hex(&bytes),
|
|
bytes.len() as u64,
|
|
"application/pdf",
|
|
"r.pdf",
|
|
"receipt",
|
|
NOW + 1 - UPLOAD_TTL,
|
|
)
|
|
.unwrap();
|
|
let mut body = Slow(&bytes, std::time::Duration::from_millis(1100));
|
|
let e = u.receive(&g.id, "tok_a", &mut body, NOW).unwrap_err();
|
|
assert!(matches!(e, UploadError::Unknown(_)), "{e}");
|
|
let folder = dir.path().join(UPLOADS_DIR);
|
|
assert!(!folder.join(format!("{}.bin", g.id)).exists());
|
|
assert!(!folder.join(format!("{}.part", g.id)).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_that_finish_after_anothers_were_kept_or_held_are_not_kept() {
|
|
// A send is under way when the books are opened again (its `.part`
|
|
// dropped as a stopped server's half send) and another send of the
|
|
// same bytes finishes first: those are kept, or held by a call that
|
|
// took them. The first send, finishing, finds that and keeps
|
|
// nothing.
|
|
for hold in [false, true] {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 twice".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
let held = std::cell::RefCell::new(None);
|
|
let mut body = Forgetting {
|
|
bytes: &bytes,
|
|
then: Some(Box::new(|| {
|
|
let again = Uploads::open(dir.path(), NOW).unwrap();
|
|
again.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
if hold {
|
|
*held.borrow_mut() = Some(again.take(&g.id, "tok_a", NOW).unwrap());
|
|
}
|
|
})),
|
|
};
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut body, NOW).unwrap_err(),
|
|
UploadError::AlreadySent,
|
|
"held: {hold}"
|
|
);
|
|
drop(body);
|
|
// The other send's bytes are what the grant has.
|
|
let r = held
|
|
.into_inner()
|
|
.unwrap_or_else(|| u.take(&g.id, "tok_a", NOW).unwrap());
|
|
assert_eq!(r.bytes, bytes);
|
|
assert!(
|
|
!dir.path()
|
|
.join(UPLOADS_DIR)
|
|
.join(format!("{}.part", g.id))
|
|
.exists()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_lasts_fifteen_minutes() {
|
|
// Plan 4.7: "valid 15 minutes".
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 in time".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
assert_eq!(g.expires, NOW + 15 * 60);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW + 15 * 60 - 1)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
u.take(&g.id, "tok_a", NOW + 15 * 60).unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_grant_counts_against_its_token_until_the_moment_it_lapses() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let g = grant_for(&u, "tok_a", b"x");
|
|
assert_eq!(u.live_for("tok_a", g.expires - 1), 1);
|
|
// Lapsed at its expiry, as a send then finds (not yet swept).
|
|
assert_eq!(u.live_for("tok_a", g.expires), 0);
|
|
assert_eq!(u.live_for("tok_b", NOW), 0);
|
|
}
|
|
|
|
/// A body that must never be read.
|
|
struct Unread;
|
|
|
|
impl std::io::Read for Unread {
|
|
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
|
|
panic!("bytes already sent are never read again");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_sent_or_held_are_refused_before_anything_more_is_read() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 once".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
// Kept: refused at once, whatever comes.
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(),
|
|
UploadError::AlreadySent
|
|
);
|
|
// Held by a call: the same.
|
|
let held = u.take(&g.id, "tok_a", NOW).unwrap();
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut Unread, NOW).unwrap_err(),
|
|
UploadError::AlreadySent
|
|
);
|
|
assert!(
|
|
!dir.path()
|
|
.join(UPLOADS_DIR)
|
|
.join(format!("{}.part", g.id))
|
|
.exists()
|
|
);
|
|
drop(held);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_send_the_disk_refuses_says_so_not_that_another_is_under_way() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 refused".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
let folder = dir.path().join(UPLOADS_DIR);
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
// (Skipped where nothing is refused, as for root.)
|
|
if std::fs::write(folder.join("probe"), b"").is_err() {
|
|
assert_eq!(
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap_err(),
|
|
UploadError::Io(std::io::ErrorKind::PermissionDenied)
|
|
);
|
|
}
|
|
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
// The grant still stands.
|
|
let _ = std::fs::remove_file(folder.join("probe"));
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_is_plain_and_visible() {
|
|
for ok in ["r.pdf", "Résumé 2026 (final).pdf", "收据.png", "a"] {
|
|
assert_eq!(file_name_problem(ok), None, "{ok}");
|
|
}
|
|
for bad in [
|
|
"",
|
|
" ",
|
|
".",
|
|
"..",
|
|
"a/b",
|
|
"a\\b",
|
|
"a\nb",
|
|
"a\u{202E}b",
|
|
"a\u{200B}b",
|
|
"a\u{E0041}b",
|
|
"a\u{2028}b",
|
|
"a\u{FE0F}b",
|
|
"a\u{3164}b",
|
|
"a\u{2800}b",
|
|
"a\u{0890}b",
|
|
] {
|
|
assert!(file_name_problem(bad).is_some(), "{bad:?}");
|
|
}
|
|
assert_eq!(file_name_problem(&"a".repeat(MAX_NAME_BYTES)), None);
|
|
assert!(file_name_problem(&"a".repeat(MAX_NAME_BYTES + 1)).is_some());
|
|
for ok in ["application/pdf", "text/plain; charset=utf-8", "a/b"] {
|
|
assert_eq!(mime_problem(ok), None, "{ok}");
|
|
}
|
|
let long = format!("a/{}", "b".repeat(125));
|
|
assert_eq!(mime_problem(&long), None);
|
|
for bad in [
|
|
"pdf",
|
|
"/pdf",
|
|
"a/",
|
|
"a/b\r\nX: y",
|
|
"a/é",
|
|
&format!("{long}b"),
|
|
] {
|
|
assert!(mime_problem(bad).is_some(), "{bad:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bytes_changed_after_they_came_are_never_filed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let bytes = b"%PDF-1.4 as sent".to_vec();
|
|
let g = grant_for(&u, "tok_a", &bytes);
|
|
u.receive(&g.id, "tok_a", &mut &bytes[..], NOW).unwrap();
|
|
let bin = dir.path().join(UPLOADS_DIR).join(format!("{}.bin", g.id));
|
|
std::fs::write(&bin, b"%PDF-1.4 as kept").unwrap();
|
|
assert_eq!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::WrongHash
|
|
);
|
|
assert!(matches!(
|
|
u.take(&g.id, "tok_a", NOW).unwrap_err(),
|
|
UploadError::Unknown(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn making_a_grant_sweeps_the_lapsed() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let old = grant_for(&u, "tok_a", b"old");
|
|
u.receive(&old.id, "tok_a", &mut &b"old"[..], NOW).unwrap();
|
|
u.grant(
|
|
"tok_a",
|
|
&sha256_hex(b"new"),
|
|
3,
|
|
"text/plain",
|
|
"x",
|
|
"receipt",
|
|
NOW + UPLOAD_TTL,
|
|
)
|
|
.unwrap();
|
|
let names: Vec<String> = std::fs::read_dir(dir.path().join(UPLOADS_DIR))
|
|
.unwrap()
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
|
|
.collect();
|
|
assert!(names.iter().all(|n| !n.starts_with(&old.id)), "{names:?}");
|
|
assert_eq!(names.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_token_has_only_so_many_uploads_under_way() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let sha = sha256_hex(b"x");
|
|
for _ in 0..MAX_LIVE {
|
|
u.grant("tok_a", &sha, 1, "text/plain", "x", "w9", NOW)
|
|
.unwrap();
|
|
}
|
|
let e = u
|
|
.grant("tok_a", &sha, 1, "text/plain", "x", "receipt", NOW)
|
|
.unwrap_err();
|
|
assert!(matches!(e, UploadError::Bad(_)), "{e}");
|
|
// Another token's are its own; lapsed ones don't count.
|
|
u.grant("tok_b", &sha, 1, "text/plain", "x", "receipt", NOW)
|
|
.unwrap();
|
|
u.grant(
|
|
"tok_a",
|
|
&sha,
|
|
1,
|
|
"text/plain",
|
|
"x",
|
|
"receipt",
|
|
NOW + UPLOAD_TTL,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn its_folder_and_files_are_private() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let u = Uploads::open(dir.path(), NOW).unwrap();
|
|
let g = grant_for(&u, "t", b"abc");
|
|
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode(&dir.path().join(UPLOADS_DIR)), 0o700);
|
|
assert_eq!(
|
|
mode(&dir.path().join(UPLOADS_DIR).join(format!("{}.json", g.id))),
|
|
0o600
|
|
);
|
|
// The bytes, kept and held, are private too.
|
|
let folder = dir.path().join(UPLOADS_DIR);
|
|
u.receive(&g.id, "t", &mut &b"abc"[..], NOW).unwrap();
|
|
assert_eq!(mode(&folder.join(format!("{}.bin", g.id))), 0o600);
|
|
let held = u.take(&g.id, "t", NOW).unwrap();
|
|
assert_eq!(mode(&folder.join(format!("{}.{TAKEN}", g.id))), 0o600);
|
|
drop(held);
|
|
}
|
|
}
|