//! TRACES: FR-CAT-9 | FR-NC-10 | FR-CAT-8 //! A local copy of every sidecar this device has seen, and the outbox of the //! ones the server has not. //! //! # Why a cache at all //! //! FR-CAT-9: *"edits queue and apply when the source returns."* Before this, //! a rating or a pasted edit made with no connection was dropped — the //! judgement survived in the catalog, which is disposable (ARCH §6.12), and a //! pasted edit survived nowhere at all. An edit that vanishes because the //! train went into a tunnel is the worst failure an authoritative store can //! have, and it is silent. //! //! So the local file is the **commit point** and the upload is best-effort. //! Writing is always a local write first; the network attempt follows and, if //! it fails or was never possible, the entry stays marked and is retried when //! the server comes back. Online and offline are therefore the same code path //! differing only in whether the upload is attempted, rather than two paths //! where one quietly does less. //! //! # Why the filesystem is the queue //! //! The catalog has a `jobs` table with backoff and coalescing, and //! `JobKind::WriteSidecar` was reserved for this. It is deliberately not used: //! the catalog is rebuildable and may be deleted at any time, and a queue of //! *unuploaded edits* is the one thing in this system that cannot be //! reconstructed from anywhere else. A pending marker sitting beside the //! document it refers to survives a catalog deletion, an app reinstall that //! keeps app data, and a crash — because there is no separate index that could //! disagree with it. //! //! It is also debuggable in the way the sidecar format itself is meant to be: //! the cache mirrors the server's layout, so the file holding an edit that //! failed to upload is at the path you would guess, and a `.pending` marker //! beside it says why it is still there. //! //! # Why there is no budget //! //! Unlike cached originals (FR-NC-6a), sidecars are hundreds of bytes. A //! hundred-thousand-image library is tens of megabytes, and evicting one would //! cost a round-trip to re-read an edit the user is about to open. The //! originals cache exists because RAW files are 30 MB each; this does not have //! the problem that motivates one. use std::path::{Path, PathBuf}; use dr_pipeline::Sidecar; /// Suffix marking a cached sidecar the server has not yet accepted. /// /// A separate zero-byte file rather than a flag inside the document: the /// document's bytes are what gets uploaded, and a marker inside it would have /// to be stripped on the way out — one more chance to upload something subtly /// different from what was stored. const PENDING_SUFFIX: &str = ".pending"; /// The on-disk sidecar cache for one library. pub struct SidecarCache { dir: PathBuf, } impl SidecarCache { /// Open the cache rooted at `dir`. Nothing is created until a write. pub fn open(dir: PathBuf) -> Self { Self { dir } } /// Where a remote sidecar is cached. /// /// Mirrors the server's layout, so `photos/2024/a.drsc` is cached at /// `/photos/2024/a.drsc`. Keyed on the **sidecar's** remote path /// rather than the image's, because that is what an upload addresses — /// deriving one from the other in two places is how they come to disagree. /// /// Returns `None` for a path that would escape the cache directory. The /// path comes from a server response, so it is not this process's to /// trust: a `..` component would let a hostile or merely broken server /// name a file anywhere this app can write. pub fn path_for(&self, remote_sidecar_path: &str) -> Option { // No directory means no library is open. Every path would otherwise // be relative and land in the process's working directory, which for a // desktop launch is wherever the user happened to be standing. if self.dir.as_os_str().is_empty() { return None; } let mut out = self.dir.clone(); let mut depth = 0usize; for part in remote_sidecar_path.split('/') { match part { // Empty from a leading or doubled slash, and `.`, both mean // "here" and are simply skipped. "" | "." => continue, ".." => return None, // A Windows-style drive or a backslash cannot appear in a // WebDAV path segment and would not mean what it looks like. p if p.contains('\\') => return None, p => { out.push(p); depth += 1; } } } (depth > 0).then_some(out) } /// The cached document, if there is one. /// /// An unreadable cache entry answers `None` rather than an error: the /// caller's fallback is to treat the photograph as unedited, and a cache /// is by definition reconstructible from the server. pub fn load(&self, remote_sidecar_path: &str) -> Option { let path = self.path_for(remote_sidecar_path)?; let text = std::fs::read_to_string(&path).ok()?; match Sidecar::parse(&text) { Ok(s) => Some(s), Err(e) => { log::warn!("cached sidecar at {} is unreadable ({e})", path.display()); None } } } /// Write a document into the cache. /// /// `pending` records whether the server has this content. Passing `false` /// after a successful upload is what takes an entry out of the outbox, so /// the marker is *removed* here rather than only in a separate call — one /// function owns the pair, and they cannot drift apart. pub fn store( &self, remote_sidecar_path: &str, sidecar: &Sidecar, pending: bool, ) -> Result<(), String> { let path = self .path_for(remote_sidecar_path) .ok_or_else(|| format!("unsafe sidecar path {remote_sidecar_path}"))?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } // Write and rename, so an interrupted save cannot truncate an edit // that was already safely on disk — the same discipline the settings // store and the local sidecar writer use. let tmp = path.with_extension("drsc.tmp"); std::fs::write(&tmp, sidecar.to_text()).map_err(|e| e.to_string())?; std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; // The marker is written *after* the document. The other order would // leave a window where a crash produced a marker pointing at content // that was never stored, and the drain would upload a stale document // believing it to be the queued one. let marker = marker_for(&path); if pending { std::fs::write(&marker, b"").map_err(|e| e.to_string())?; } else if marker.exists() { std::fs::remove_file(&marker).map_err(|e| e.to_string())?; } Ok(()) } /// Whether this entry is waiting to be uploaded. pub fn is_pending(&self, remote_sidecar_path: &str) -> bool { self.path_for(remote_sidecar_path) .is_some_and(|p| marker_for(&p).exists()) } /// Every sidecar waiting to be uploaded, as remote paths. /// /// Walks the tree rather than consulting an index, which is the property /// that makes the queue survive a catalog deletion: the markers *are* the /// queue, so there is nothing to fall out of step with them. /// /// Sorted, so a drain that is interrupted resumes in the same order rather /// than retrying whichever entry the directory happened to yield first. pub fn pending(&self) -> Vec { let mut out = Vec::new(); collect_pending(&self.dir, &self.dir, &mut out); out.sort(); out } } /// The marker path for a cached document. fn marker_for(path: &Path) -> PathBuf { let mut s = path.as_os_str().to_os_string(); s.push(PENDING_SUFFIX); PathBuf::from(s) } /// Recursively gather remote paths whose marker exists. /// /// A missing or unreadable directory contributes nothing rather than failing /// the walk: a cache that has never been written has no directory at all, and /// that is the normal state on a fresh install. fn collect_pending(root: &Path, dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_pending(root, &path, out); continue; } // The marker names the document; the document names the remote path. let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; let Some(stem) = name.strip_suffix(PENDING_SUFFIX) else { continue; }; let document = path.with_file_name(stem); // A marker whose document has gone is not a queued upload; it is // debris. Skipped rather than reported, since there is nothing to send // and nothing the user could do about it. if !document.exists() { continue; } if let Ok(rel) = document.strip_prefix(root) { // Back to a remote path: the cache mirrors the server's layout, so // the relative path *is* the remote path. let remote: Vec<&str> = rel.iter().filter_map(|c| c.to_str()).collect(); if !remote.is_empty() { out.push(remote.join("/")); } } } } #[cfg(test)] mod tests { use super::*; use dr_pipeline::sidecar::Version; fn tempdir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "dr-sidecar-cache-{name}-{}-{:?}", std::process::id(), std::thread::current().id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } /// A cache and the directory it is rooted at. fn cache(name: &str) -> (SidecarCache, PathBuf) { let root = tempdir(name).join("sidecars"); (SidecarCache::open(root.clone()), root) } fn rated(stars: u8) -> Sidecar { let mut s = Sidecar::new(); s.put(Version { uuid: "u1".into(), name: "Default".into(), is_default: true, revision: 1, rating: stars, ..Default::default() }); s } #[test] fn the_cache_mirrors_the_servers_layout() { // What makes an entry findable by hand when an upload has gone wrong, // and what lets `pending` recover a remote path with no index. let (c, _d) = cache("layout"); let p = c.path_for("photos/2024/a.drsc").unwrap(); assert!( p.ends_with("sidecars/photos/2024/a.drsc"), "{}", p.display() ); } #[test] fn a_document_survives_a_store_and_a_load() { let (c, _d) = cache("round-trip"); c.store("photos/a.drsc", &rated(4), true).unwrap(); let back = c.load("photos/a.drsc").expect("cached"); assert_eq!(back.default_version().unwrap().rating, 4); } #[test] fn an_absent_entry_loads_as_nothing() { let (c, _d) = cache("absent"); assert!(c.load("photos/never-seen.drsc").is_none()); } #[test] fn a_pending_write_appears_in_the_outbox() { // The whole point: an edit made offline is queued rather than dropped. let (c, _d) = cache("outbox"); c.store("photos/a.drsc", &rated(3), true).unwrap(); assert!(c.is_pending("photos/a.drsc")); assert_eq!(c.pending(), vec!["photos/a.drsc".to_string()]); } #[test] fn a_stored_upload_leaves_the_outbox() { let (c, _d) = cache("drained"); c.store("photos/a.drsc", &rated(3), true).unwrap(); c.store("photos/a.drsc", &rated(3), false).unwrap(); assert!(!c.is_pending("photos/a.drsc")); assert!(c.pending().is_empty()); // And the content is still cached, so an offline open still shows the // edit. Clearing the marker must not clear the document. assert_eq!( c.load("photos/a.drsc") .unwrap() .default_version() .unwrap() .rating, 3 ); } #[test] fn the_outbox_finds_entries_nested_at_any_depth() { // The walk is what stands in for an index, so it has to reach an entry // wherever the server's tree put it. let (c, _d) = cache("nested"); c.store("a.drsc", &rated(1), true).unwrap(); c.store("photos/b.drsc", &rated(1), true).unwrap(); c.store("photos/2024/spain/c.drsc", &rated(1), true) .unwrap(); c.store("photos/d.drsc", &rated(1), false).unwrap(); assert_eq!( c.pending(), vec![ "a.drsc".to_string(), "photos/2024/spain/c.drsc".to_string(), "photos/b.drsc".to_string(), ] ); } #[test] fn an_empty_cache_has_an_empty_outbox() { // A fresh install has no directory at all; the walk must not fail. let (c, _d) = cache("fresh"); assert!(c.pending().is_empty()); } #[test] fn a_marker_without_its_document_is_not_a_queued_upload() { // Debris from an interrupted write. There is nothing to send, and // reporting it as queued would leave the outbox permanently non-empty. let (c, _d) = cache("debris"); c.store("photos/a.drsc", &rated(1), true).unwrap(); std::fs::remove_file(c.path_for("photos/a.drsc").unwrap()).unwrap(); assert!(c.pending().is_empty()); } #[test] fn a_path_escaping_the_cache_is_refused() { // The remote path comes from a server response and is not this // process's to trust. let (c, _d) = cache("escape"); assert!(c.path_for("../../etc/passwd.drsc").is_none()); assert!(c.path_for("photos/../../../a.drsc").is_none()); assert!(c.path_for("").is_none()); assert!(c.store("../evil.drsc", &rated(1), true).is_err()); } #[test] fn a_cache_with_no_directory_is_inert() { // What a caller holds before a library is open. Writing relative to // the working directory would scatter sidecars wherever the app was // launched from. let c = SidecarCache::open(PathBuf::new()); assert!(c.path_for("photos/a.drsc").is_none()); assert!(c.load("photos/a.drsc").is_none()); assert!(c.store("photos/a.drsc", &rated(1), true).is_err()); assert!(c.pending().is_empty()); } #[test] fn a_leading_slash_is_not_an_absolute_path() { // WebDAV paths often arrive with one. Treating it as absolute would // push the write to the filesystem root. let (c, root) = cache("leading-slash"); let p = c.path_for("/photos/a.drsc").unwrap(); assert!(p.starts_with(&root), "{}", p.display()); } #[test] fn a_corrupt_cache_entry_reads_as_absent_rather_than_failing() { // A cache is reconstructible from the server by definition, so the // fallback is to fetch — never to refuse to open the photograph. let (c, _d) = cache("corrupt"); c.store("photos/a.drsc", &rated(1), false).unwrap(); std::fs::write(c.path_for("photos/a.drsc").unwrap(), "drsc 99\n").unwrap(); assert!(c.load("photos/a.drsc").is_none()); } #[test] fn storing_leaves_no_temporary_file_behind() { let (c, root) = cache("no-temp"); c.store("photos/a.drsc", &rated(1), true).unwrap(); let leftovers: Vec<_> = std::fs::read_dir(root.join("photos")) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .filter(|n| n.ends_with(".tmp")) .collect(); assert!(leftovers.is_empty(), "left {leftovers:?} behind"); } }