//! 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");
}
}