//! Local storage for attachment bytes (M10.7d). //! //! Content-addressed: a blob is filed under its own sha256, so the same image //! attached to five notes is stored once and re-downloading it is free. The hash is //! also the integrity check — bytes that don't hash to what the server advertised //! are refused rather than filed under a name that lies about them. //! //! Attachment METADATA rides the delta feed; only the bytes come through here //! (docs/sync.md). use std::fs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; /// A sha256 in lowercase hex, and nothing else. /// /// This is a **path-safety** check, not a formatting nicety: the hash is taken /// straight from a server response and used as a filename. Without it, a hostile or /// buggy server could send `../../…` and steer a write outside the blob directory. fn is_hash(candidate: &str) -> bool { candidate.len() == 64 && candidate.bytes().all(|b| b.is_ascii_hexdigit()) } fn digest(bytes: &[u8]) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(bytes); hasher .finalize() .iter() .map(|b| format!("{b:02x}")) .collect() } pub struct BlobStore { root: PathBuf, } impl BlobStore { /// Open (creating if needed) the blob directory. pub fn new(root: PathBuf) -> std::io::Result { fs::create_dir_all(&root)?; Ok(Self { root }) } pub fn root(&self) -> &Path { &self.root } /// Where a blob lives, or `None` if the hash isn't one. pub fn path(&self, sha256: &str) -> Option { let lower = sha256.to_ascii_lowercase(); is_hash(&lower).then(|| self.root.join(lower)) } /// Whether we already hold these bytes. Drives the "don't download it twice" /// skip, which is the entire point of keying by content. pub fn has(&self, sha256: &str) -> bool { self.path(sha256).is_some_and(|p| p.is_file()) } /// File bytes under `expected`, refusing them if they don't hash to it. /// /// Verifying on the way IN rather than on the way out means a corrupted transfer /// can never be served later as if it were genuine — and the next sync simply /// tries again, because the blob still counts as missing. pub fn store(&self, expected: &str, bytes: &[u8]) -> Result { let path = self .path(expected) .ok_or_else(|| format!("refusing an attachment with a malformed hash: {expected}"))?; let actual = digest(bytes); if actual != expected.to_ascii_lowercase() { return Err(format!( "attachment failed its integrity check (expected {expected}, got {actual})" )); } fs::write(&path, bytes).map_err(|e| format!("couldn't save an attachment: {e}"))?; Ok(path) } pub fn read(&self, sha256: &str) -> Option> { fs::read(self.path(sha256)?).ok() } } // --- Serving blobs to the webview (M10.7f) ----------------------------------- // // A synced note's attachment `url` is the SERVER's relative path // (`/api/notes//attachments/`). In the desktop webview that resolves // against the app origin and 404s, and swapping in the absolute server URL wouldn't // help either — that route needs a bearer token the webview won't send, and it would // make an offline app fetch over the network to show a file it already has on disk. // // So the bytes are served locally, over a custom URI scheme, straight out of this // store. The webview then caches and range-requests them like any other resource, // which a `data:` URI would have thrown away. /// The scheme the webview fetches attachment bytes over. pub const BLOB_SCHEME: &str = "tsblob"; /// The blob directory, published once the app has resolved its data dir. /// /// A `OnceLock` rather than Tauri's managed state because the scheme handler is /// registered on the BUILDER, before `setup` has computed that path — and because /// reading it this way keeps the handler independent of which Tauri 2.x minor /// changed the handler's context argument. static SERVE_ROOT: OnceLock = OnceLock::new(); pub fn publish_root(root: PathBuf) { let _ = SERVE_ROOT.set(root); } /// The URL an ``/`