//! Content-addressed archive of the searchable PDF produced when an invoice is issued. //! Files live at `/archive/.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, #[serde(skip_serializing_if = "Option::is_none")] pub fingerprint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub archived_at: Option, } 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 { 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 { required_header(headers, "x-invoice-id")? .trim() .parse::() .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 { 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)> = 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, 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, String> { let row: Option> = 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 { let row: Option<(Option, Option, Option)> = 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 { 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 { 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 { 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"", &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")); } }