The searchable PDF can be archived content-addressed under the local data dir with its sha256 and layout fingerprint; a different PDF for an archived invoice is refused and reads verify the hash. Export writes go through a raw-body command that remembers the last folder; reveal-in-folder and open fall back to xdg-open / explorer. Adds a Windows-safe export file name builder. Migration M4 adds the archive columns and last export dir.
290 lines
12 KiB
Rust
290 lines
12 KiB
Rust
//! Content-addressed archive of the searchable PDF produced when an invoice is issued.
|
|
//! Files live at `<local data dir>/archive/<sha256>.pdf`; the invoice row keeps the hash.
|
|
use super::raw::{header_map, raw_body, required_header, write_atomic, Headers};
|
|
use crate::AppState;
|
|
use rusqlite::{params, Connection, OptionalExtension};
|
|
use serde::Serialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::{Path, PathBuf};
|
|
use tauri::ipc::{Request, Response};
|
|
use tauri::State;
|
|
|
|
pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024;
|
|
const MAX_FINGERPRINT_LEN: usize = 256;
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ArchiveStatus {
|
|
pub archived: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub sha256: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub fingerprint: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub archived_at: Option<String>,
|
|
}
|
|
|
|
fn sha256_hex(bytes: &[u8]) -> String {
|
|
format!("{:x}", Sha256::digest(bytes))
|
|
}
|
|
|
|
/// The hash comes from the database, but it ends up in a file path, so check its shape.
|
|
fn archive_path(local_dir: &Path, sha256: &str) -> Result<PathBuf, String> {
|
|
let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
|
|
if !valid {
|
|
return Err("The stored archive hash is malformed".to_string());
|
|
}
|
|
Ok(local_dir.join("archive").join(format!("{sha256}.pdf")))
|
|
}
|
|
|
|
fn parse_invoice_id(headers: &Headers) -> Result<i64, String> {
|
|
required_header(headers, "x-invoice-id")?
|
|
.trim()
|
|
.parse::<i64>()
|
|
.map_err(|_| "x-invoice-id must be a number".to_string())
|
|
}
|
|
|
|
pub fn archive_pdf_impl(
|
|
conn: &mut Connection,
|
|
local_dir: &Path,
|
|
bytes: &[u8],
|
|
headers: &Headers,
|
|
) -> Result<String, String> {
|
|
let id = parse_invoice_id(headers)?;
|
|
let fingerprint = required_header(headers, "x-fingerprint")?.trim();
|
|
if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN {
|
|
return Err("x-fingerprint must be 1-256 characters".to_string());
|
|
}
|
|
if !bytes.starts_with(b"%PDF-") {
|
|
return Err("The data is not a PDF".to_string());
|
|
}
|
|
if bytes.len() > MAX_ARCHIVE_BYTES {
|
|
return Err("The PDF is larger than the 64 MB archive limit".to_string());
|
|
}
|
|
|
|
let row: Option<(String, Option<String>)> = conn
|
|
.query_row(
|
|
"SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1",
|
|
params![id],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.optional()
|
|
.map_err(|e| e.to_string())?;
|
|
let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
|
if status != "issued" && status != "cancelled" {
|
|
return Err(format!("Only an issued invoice can be archived (this one is {status})"));
|
|
}
|
|
|
|
let sha = sha256_hex(bytes);
|
|
if let Some(existing) = &existing {
|
|
if *existing != sha {
|
|
return Err(
|
|
"This invoice already has an archived original; the issued PDF cannot be replaced"
|
|
.to_string(),
|
|
);
|
|
}
|
|
}
|
|
|
|
let path = archive_path(local_dir, &sha)?;
|
|
std::fs::create_dir_all(path.parent().expect("archive path has a parent"))
|
|
.map_err(|e| format!("Could not create the archive folder: {e}"))?;
|
|
// Same content, same name; a matching length means the file is already in place.
|
|
let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false);
|
|
if !present {
|
|
write_atomic(&path, bytes)?;
|
|
}
|
|
|
|
if existing.is_none() {
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
conn.execute(
|
|
"UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3
|
|
WHERE id = ?4",
|
|
params![sha, fingerprint, now, id],
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(sha)
|
|
}
|
|
|
|
pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result<Vec<u8>, String> {
|
|
let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?;
|
|
let path = archive_path(local_dir, &sha)?;
|
|
let bytes = std::fs::read(&path).map_err(|e| {
|
|
if e.kind() == std::io::ErrorKind::NotFound {
|
|
"The archived PDF file is missing".to_string()
|
|
} else {
|
|
format!("Could not read the archived PDF: {e}")
|
|
}
|
|
})?;
|
|
if sha256_hex(&bytes) != sha {
|
|
return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string());
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn stored_sha(conn: &Connection, id: i64) -> Result<Option<String>, String> {
|
|
let row: Option<Option<String>> = conn
|
|
.query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0))
|
|
.optional()
|
|
.map_err(|e| e.to_string())?;
|
|
row.ok_or_else(|| "Invoice not found".to_string())
|
|
}
|
|
|
|
pub fn archive_status_impl(conn: &Connection, id: i64) -> Result<ArchiveStatus, String> {
|
|
let row: Option<(Option<String>, Option<String>, Option<String>)> = conn
|
|
.query_row(
|
|
"SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1",
|
|
params![id],
|
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
)
|
|
.optional()
|
|
.map_err(|e| e.to_string())?;
|
|
let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?;
|
|
Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at })
|
|
}
|
|
|
|
// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread.
|
|
#[tauri::command]
|
|
pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result<String, String> {
|
|
let bytes = raw_body(&request)?;
|
|
let headers = header_map(&request);
|
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result<Response, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result<ArchiveStatus, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
archive_status_impl(&conn, invoice_id)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
fn conn_with_invoice(status: &str) -> Connection {
|
|
let conn = crate::db::open_in_memory().unwrap();
|
|
conn.execute(
|
|
"INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at)
|
|
VALUES ('INV/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')",
|
|
params![status],
|
|
)
|
|
.unwrap();
|
|
conn
|
|
}
|
|
|
|
fn headers(id: &str) -> Headers {
|
|
Headers::from([
|
|
("x-invoice-id".to_string(), id.to_string()),
|
|
("x-fingerprint".to_string(), "fp-1".to_string()),
|
|
])
|
|
}
|
|
|
|
const PDF: &[u8] = b"%PDF-1.7\nbody";
|
|
|
|
#[test]
|
|
fn archives_once_and_is_idempotent() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("issued");
|
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
|
assert_eq!(sha.len(), 64);
|
|
assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF);
|
|
|
|
let status = archive_status_impl(&conn, 1).unwrap();
|
|
assert!(status.archived);
|
|
assert_eq!(status.sha256.as_deref(), Some(sha.as_str()));
|
|
assert_eq!(status.fingerprint.as_deref(), Some("fp-1"));
|
|
let first_at = status.archived_at.clone().unwrap();
|
|
|
|
let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
|
assert_eq!(again, sha);
|
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at);
|
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_invoices_can_be_archived() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("cancelled");
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn different_bytes_for_an_archived_invoice_are_refused() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("issued");
|
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
|
let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err();
|
|
assert!(err.contains("already has an archived original"), "{err}");
|
|
assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha);
|
|
assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn non_pdf_and_oversize_bodies_are_refused() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("issued");
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), b"<html>", &headers("1"))
|
|
.unwrap_err()
|
|
.contains("not a PDF"));
|
|
let mut big = b"%PDF-".to_vec();
|
|
big.resize(MAX_ARCHIVE_BYTES + 1, 0);
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1"))
|
|
.unwrap_err()
|
|
.contains("64 MB"));
|
|
assert!(!archive_status_impl(&conn, 1).unwrap().archived);
|
|
}
|
|
|
|
#[test]
|
|
fn drafts_unknown_invoices_and_bad_headers_are_refused() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("draft");
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1"))
|
|
.unwrap_err()
|
|
.contains("Only an issued invoice"));
|
|
assert_eq!(
|
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(),
|
|
"Invoice not found"
|
|
);
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err());
|
|
let mut no_fp = headers("1");
|
|
no_fp.remove("x-fingerprint");
|
|
assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err());
|
|
assert!(!dir.path().join("archive").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn read_archive_detects_corruption_and_missing_files() {
|
|
let dir = tempdir().unwrap();
|
|
let mut conn = conn_with_invoice("issued");
|
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF"));
|
|
let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
|
let file = dir.path().join("archive").join(format!("{sha}.pdf"));
|
|
|
|
std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap();
|
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted"));
|
|
|
|
std::fs::remove_file(&file).unwrap();
|
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing"));
|
|
// Archiving the same bytes again restores the file.
|
|
archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap();
|
|
assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF);
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_stored_hash_never_becomes_a_path() {
|
|
let dir = tempdir().unwrap();
|
|
let conn = conn_with_invoice("issued");
|
|
conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", [])
|
|
.unwrap();
|
|
assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed"));
|
|
}
|
|
}
|