FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan` stepped over a NotFound or PermissionDenied the way it does for a child that vanished mid-walk -- correct for a child, wrong for the root, where it ended the walk, returned Ok with nothing in it, and reported a successful scan of a library that was no longer there. A lost root is now its own error. The images under it are marked Availability::Offline per FR-CAT-9 and no catalog row is deleted; `library::persist` clears the mark per file as each one is listed again, so a root that comes back needs no repair step. Partly satisfied rather than closed, and the gap is worth stating. The recovery half is real and reachable on Android today, because `map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how a phone actually gets a library in this build. The causes the requirement names -- revocation, reinstall, a removed card -- are properties of a persisted tree permission, and there is none: SAF does not exist here, `SourceRef::Document` is constructed only in test modules, and `LocalStorage` rejects the variant outright. When SAF lands it becomes a third producer of this error and nothing above it changes, which is why the discovery belongs in the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
751 lines
24 KiB
Rust
751 lines
24 KiB
Rust
//! Behaviour of the folder connector, against real directories.
|
|
//!
|
|
//! No mocks: the whole point of this backend is what a filesystem actually
|
|
//! does, and a double would only assert what this file assumes.
|
|
|
|
use super::*;
|
|
use dr_sync::{scan, RemoteBackend};
|
|
use dr_types::FormatFilter;
|
|
use std::collections::HashMap;
|
|
|
|
/// A throwaway library root.
|
|
///
|
|
/// Under the system temp directory, named for the test, and cleared first so a
|
|
/// crashed run cannot leave state that makes the next one pass.
|
|
struct Tmp(PathBuf);
|
|
|
|
impl Tmp {
|
|
fn new(name: &str) -> Self {
|
|
let d = std::env::temp_dir().join(format!("dr-folder-test-{name}"));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
Tmp(d)
|
|
}
|
|
|
|
fn file(&self, rel: &str, body: &[u8]) -> &Self {
|
|
let p = self.0.join(rel);
|
|
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
std::fs::write(p, body).unwrap();
|
|
self
|
|
}
|
|
|
|
fn backend(&self) -> FolderBackend {
|
|
FolderBackend::new(&self.0).unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for Tmp {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn names(entries: &[RemoteEntry]) -> Vec<String> {
|
|
let mut v: Vec<String> = entries.iter().map(|e| e.path.name().to_string()).collect();
|
|
v.sort();
|
|
v
|
|
}
|
|
|
|
// --- opening --------------------------------------------------------------
|
|
|
|
/// TRACES: FR-PLAT-AND-2
|
|
#[test]
|
|
fn a_missing_folder_is_an_unavailable_root_not_a_network_failure() {
|
|
// Still not offline mode — nothing was unreachable over a wire, and
|
|
// reporting it as a network failure would tell the user to wait for a
|
|
// connection that is working.
|
|
//
|
|
// `RootUnavailable` rather than `Configuration`, because the caller that
|
|
// has to act on this is the one whose library worked yesterday: an
|
|
// ejected card is indistinguishable from a mistyped path here, and only
|
|
// the first of those has a catalog full of ratings to protect.
|
|
let err = FolderBackend::new("/definitely/not/here").unwrap_err();
|
|
assert!(matches!(err, RemoteError::RootUnavailable(_)), "{err:?}");
|
|
assert!(err.indicates_lost_root());
|
|
assert!(!err.indicates_offline());
|
|
assert!(err.to_string().contains("/definitely/not/here"), "{err}");
|
|
}
|
|
|
|
// --- listing --------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn listing_reports_files_and_directories() {
|
|
let t = Tmp::new("list");
|
|
t.file("a.CR2", b"raw").file("sub/b.CR2", b"raw");
|
|
let b = t.backend();
|
|
|
|
let root = b.list(&RemotePath::root(), None).await.unwrap();
|
|
assert_eq!(names(&root), vec!["a.CR2", "sub"]);
|
|
|
|
let kinds: HashMap<_, _> = root
|
|
.iter()
|
|
.map(|e| (e.path.name().to_string(), e.kind))
|
|
.collect();
|
|
assert_eq!(kinds["a.CR2"], EntryKind::File);
|
|
assert_eq!(kinds["sub"], EntryKind::Directory);
|
|
|
|
let sub = b.list(&RemotePath::new("sub"), None).await.unwrap();
|
|
assert_eq!(names(&sub), vec!["b.CR2"]);
|
|
// Paths are rooted at the library, not at the filesystem.
|
|
assert_eq!(sub[0].path.as_str(), "sub/b.CR2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_listing_carries_the_size_a_scan_needs() {
|
|
let t = Tmp::new("size");
|
|
t.file("a.CR2", &[7u8; 1234]);
|
|
let e = &t.backend().list(&RemotePath::root(), None).await.unwrap()[0];
|
|
assert_eq!(e.size, 1234);
|
|
assert!(e.modified.is_some());
|
|
// Nothing behind a folder renders anything.
|
|
assert!(!e.has_preview);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn listing_a_missing_directory_is_not_found() {
|
|
let t = Tmp::new("missing");
|
|
let e = t
|
|
.backend()
|
|
.list(&RemotePath::new("nope"), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::NotFound(_)), "{e:?}");
|
|
}
|
|
|
|
// --- identity and validators ---------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn identity_is_stable_across_an_edit_but_not_across_a_rename() {
|
|
// The catalog keys thumbnails and faces on this id, so editing a file must
|
|
// not orphan its thumbnail. A rename is a different photograph as far as
|
|
// this backend can tell, which `Capabilities::stable_ids` reports.
|
|
let t = Tmp::new("identity");
|
|
t.file("a.CR2", b"one");
|
|
let b = t.backend();
|
|
|
|
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
t.file("a.CR2", b"two-different-length");
|
|
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
assert_eq!(before, after, "an edit is not a new photograph");
|
|
|
|
std::fs::rename(t.0.join("a.CR2"), t.0.join("b.CR2")).unwrap();
|
|
let renamed = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
assert_ne!(before, renamed);
|
|
assert!(!b.capabilities().stable_ids, "and the capability says so");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn two_libraries_agree_on_the_identity_of_the_same_photograph() {
|
|
// Two devices mounting one share must key the thumbnail index the same
|
|
// way, or each re-derives what the other already stored. This is why the
|
|
// id is a path hash and not an inode.
|
|
let a = Tmp::new("id-a");
|
|
let b = Tmp::new("id-b");
|
|
a.file("2026/x.CR2", b"one");
|
|
b.file("2026/x.CR2", b"quite different bytes");
|
|
|
|
let ida = a
|
|
.backend()
|
|
.list(&RemotePath::new("2026"), None)
|
|
.await
|
|
.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
let idb = b
|
|
.backend()
|
|
.list(&RemotePath::new("2026"), None)
|
|
.await
|
|
.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
assert_eq!(ida, idb);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_validator_changes_when_the_content_does() {
|
|
let t = Tmp::new("validator");
|
|
t.file("a.CR2", b"one");
|
|
let b = t.backend();
|
|
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.validator
|
|
.clone();
|
|
|
|
// A different length, so this holds on a filesystem with one-second mtime
|
|
// granularity as well as on one with nanoseconds.
|
|
t.file("a.CR2", b"a rather longer body");
|
|
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.validator
|
|
.clone();
|
|
assert_ne!(before, after);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_folder_does_not_pretend_to_prune() {
|
|
// Answering with the directory's own mtime would let a caller skip a
|
|
// subtree whose files had been edited, hiding those edits indefinitely.
|
|
let t = Tmp::new("prune");
|
|
let b = t.backend();
|
|
assert!(matches!(
|
|
b.dir_validator(&RemotePath::root()).await,
|
|
Err(RemoteError::Unsupported(_))
|
|
));
|
|
assert_eq!(
|
|
b.capabilities().change_detection,
|
|
ChangeDetection::LocalEtags
|
|
);
|
|
}
|
|
|
|
// --- reading --------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_whole_file_and_a_range_both_read() {
|
|
let t = Tmp::new("get");
|
|
t.file("a.CR2", b"0123456789");
|
|
let b = t.backend();
|
|
let id = RemoteId::Path(RemotePath::new("a.CR2"));
|
|
|
|
assert_eq!(b.get(&id, None).await.unwrap(), b"0123456789");
|
|
assert_eq!(b.get(&id, Some(2..5)).await.unwrap(), b"234");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_range_past_the_end_returns_what_is_there() {
|
|
// The header extractor asks for a fixed window; a small JPEG is shorter
|
|
// than it, and failing would make every small file undatable.
|
|
let t = Tmp::new("shortrange");
|
|
t.file("a.JPG", b"abc");
|
|
let got = t
|
|
.backend()
|
|
.get(&RemoteId::Path(RemotePath::new("a.JPG")), Some(0..65536))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got, b"abc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_bare_identity_cannot_address_a_file() {
|
|
// Same contract as the Nextcloud connector: the id says *which*
|
|
// photograph, the path says *where*. Callers hold both.
|
|
let t = Tmp::new("byid");
|
|
t.file("a.CR2", b"x");
|
|
let e = t
|
|
.backend()
|
|
.get(&RemoteId::Stable(1), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::Unsupported(_)), "{e:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nothing_reachable_from_a_remote_path_escapes_the_library() {
|
|
// A `RemotePath` is built from names on the remote and from a catalog
|
|
// another device wrote. Resolving one against a real filesystem with the
|
|
// user's own permissions makes `..` a read of anything they own.
|
|
let t = Tmp::new("escape");
|
|
let b = t.backend();
|
|
for attempt in ["../../../etc/passwd", "sub/../../outside"] {
|
|
let e = b
|
|
.get(&RemoteId::Path(RemotePath::new(attempt)), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(e, RemoteError::Configuration(_)),
|
|
"{attempt} was not refused: {e:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- writing --------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_write_creates_the_folders_it_needs() {
|
|
let t = Tmp::new("put");
|
|
let b = t.backend();
|
|
b.put(&RemotePath::new("2026/03/a.xmp"), b"<x/>".to_vec(), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(t.0.join("2026/03/a.xmp")).unwrap(), b"<x/>");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_write_leaves_no_temporary_behind() {
|
|
// The rename-into-place is invisible from outside, and must stay that way:
|
|
// a stray `.darkroom-tmp` in a shoot folder would be listed by the scan.
|
|
let t = Tmp::new("puttmp");
|
|
let b = t.backend();
|
|
b.put(&RemotePath::new("a.xmp"), b"x".to_vec(), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
names(&b.list(&RemotePath::root(), None).await.unwrap()),
|
|
vec!["a.xmp"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_overwrite_replaces_rather_than_appends() {
|
|
let t = Tmp::new("overwrite");
|
|
t.file("a.xmp", b"the older and much longer body");
|
|
let b = t.backend();
|
|
b.put(&RemotePath::new("a.xmp"), b"new".to_vec(), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"new");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn if_absent_creates_once_and_refuses_after() {
|
|
let t = Tmp::new("ifabsent");
|
|
let b = t.backend();
|
|
let p = RemotePath::new("a.xmp");
|
|
|
|
b.put(&p, b"first".to_vec(), Some(Precondition::IfAbsent))
|
|
.await
|
|
.unwrap();
|
|
let e = b
|
|
.put(&p, b"second".to_vec(), Some(Precondition::IfAbsent))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
|
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"first");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn if_match_writes_on_the_expected_version_and_refuses_a_stale_one() {
|
|
// The sidecar conflict path (ARCH §8.5): a failure here means another
|
|
// device wrote first, and triggers a merge rather than an overwrite.
|
|
let t = Tmp::new("ifmatch");
|
|
t.file("a.xmp", b"one");
|
|
let b = t.backend();
|
|
let p = RemotePath::new("a.xmp");
|
|
|
|
let current = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.validator
|
|
.clone();
|
|
let after = b
|
|
.put(
|
|
&p,
|
|
b"two".to_vec(),
|
|
Some(Precondition::IfMatch(current.clone())),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_ne!(after, current);
|
|
|
|
let e = b
|
|
.put(&p, b"three".to_vec(), Some(Precondition::IfMatch(current)))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
|
assert_eq!(std::fs::read(t.0.join("a.xmp")).unwrap(), b"two");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_validator_a_write_returns_is_the_one_a_listing_reports() {
|
|
// Otherwise the next conditional write fails against a file nobody else
|
|
// touched, and every sidecar update becomes a spurious conflict.
|
|
let t = Tmp::new("putvalidator");
|
|
let b = t.backend();
|
|
let p = RemotePath::new("a.xmp");
|
|
let written = b.put(&p, b"body".to_vec(), None).await.unwrap();
|
|
let listed = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.validator
|
|
.clone();
|
|
assert_eq!(written, listed);
|
|
}
|
|
|
|
// --- moving and deleting --------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn a_move_creates_the_trash_folder_it_needs() {
|
|
// The soft delete (FR-CAT-15): the trash does not exist until the first
|
|
// photograph goes into it, and the trait promises the move makes it.
|
|
let t = Tmp::new("move");
|
|
t.file("a.CR2", b"raw");
|
|
let b = t.backend();
|
|
|
|
b.move_to(
|
|
&RemoteId::Path(RemotePath::new("a.CR2")),
|
|
&RemotePath::new(".darkroom-trash/a.CR2"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!t.0.join("a.CR2").exists());
|
|
assert_eq!(
|
|
std::fs::read(t.0.join(".darkroom-trash/a.CR2")).unwrap(),
|
|
b"raw"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_a_file_removes_it() {
|
|
let t = Tmp::new("delete");
|
|
t.file("a.CR2", b"raw");
|
|
let b = t.backend();
|
|
b.delete(&RemoteId::Path(RemotePath::new("a.CR2")), None)
|
|
.await
|
|
.unwrap();
|
|
assert!(!t.0.join("a.CR2").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deleting_refuses_to_take_a_tree_with_it() {
|
|
// Deliberately unlike WebDAV. There is no server-side trash behind a local
|
|
// folder, so a caller with a wrong path would have no way back.
|
|
let t = Tmp::new("deletetree");
|
|
t.file("shoot/a.CR2", b"raw");
|
|
let e = t
|
|
.backend()
|
|
.delete(&RemoteId::Path(RemotePath::new("shoot")), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::Configuration(_)), "{e:?}");
|
|
assert!(t.0.join("shoot/a.CR2").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_conditional_delete_refuses_a_file_that_changed() {
|
|
let t = Tmp::new("deletecond");
|
|
t.file("a.CR2", b"raw");
|
|
let b = t.backend();
|
|
let stale = Validator::new("0-0.0");
|
|
let e = b
|
|
.delete(
|
|
&RemoteId::Path(RemotePath::new("a.CR2")),
|
|
Some(Precondition::IfMatch(stale)),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
|
assert!(t.0.join("a.CR2").exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn creating_a_directory_twice_succeeds() {
|
|
// Callers use this to guarantee a destination, not to claim they made it.
|
|
let t = Tmp::new("mkdir");
|
|
let b = t.backend();
|
|
let p = RemotePath::new("2026/03");
|
|
b.create_dir(&p).await.unwrap();
|
|
b.create_dir(&p).await.unwrap();
|
|
assert!(t.0.join("2026/03").is_dir());
|
|
}
|
|
|
|
// --- driven by the engine -------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn the_scan_engine_walks_a_folder_library() {
|
|
// The claim this whole crate makes: the engine written for one backend
|
|
// drives another with no change. Nothing below is folder-specific.
|
|
let t = Tmp::new("scan");
|
|
t.file("2026/03/a.CR2", b"raw")
|
|
.file("2026/03/b.JPG", b"jpeg")
|
|
.file("2026/04/c.CR2", b"raw")
|
|
.file("2026/notes.txt", b"text")
|
|
.file(".darkroom-trash/deleted.CR2", b"raw");
|
|
|
|
let result = scan(
|
|
&t.backend(),
|
|
&RemotePath::root(),
|
|
&FormatFilter::from_formats([dr_types::Format::Cr2]),
|
|
&HashMap::new(),
|
|
|_| {},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let found: Vec<&str> = result.images.iter().map(|e| e.path.as_str()).collect();
|
|
// The filter picked the RAWs; the trash was skipped, or the soft delete
|
|
// would undo itself on the next scan.
|
|
assert_eq!(found, vec!["2026/03/a.CR2", "2026/04/c.CR2"]);
|
|
assert_eq!(result.progress.directories_pruned, 0, "nothing to prune");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_upload_lands_where_the_engine_places_it() {
|
|
let t = Tmp::new("upload");
|
|
let b = t.backend();
|
|
let placed = dr_sync::upload_original(
|
|
&b,
|
|
&RemotePath::root(),
|
|
&["2026".to_string(), "03".to_string()],
|
|
"a.CR2",
|
|
b"raw".to_vec(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(placed.path().as_str(), "2026/03/a.CR2");
|
|
assert_eq!(std::fs::read(t.0.join("2026/03/a.CR2")).unwrap(), b"raw");
|
|
}
|
|
|
|
// --- the provider ---------------------------------------------------------
|
|
|
|
#[test]
|
|
fn an_endpoint_is_checked_before_an_account_is_written_for_it() {
|
|
let t = Tmp::new("provider");
|
|
let p = FolderProvider::new();
|
|
|
|
assert!(p.normalise_endpoint(" ").is_err(), "empty");
|
|
assert!(p.normalise_endpoint("Pictures").is_err(), "relative");
|
|
assert!(p.normalise_endpoint("/no/such/place").is_err(), "missing");
|
|
|
|
t.file("a.CR2", b"x");
|
|
assert!(
|
|
p.normalise_endpoint(&t.0.join("a.CR2").to_string_lossy())
|
|
.is_err(),
|
|
"a file is not a library"
|
|
);
|
|
|
|
let ok = p.normalise_endpoint(&t.0.to_string_lossy()).unwrap();
|
|
assert_eq!(PathBuf::from(&ok), t.0.canonicalize().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn two_spellings_of_one_folder_become_one_account() {
|
|
// Otherwise the same photographs are indexed twice, into two catalogs.
|
|
let t = Tmp::new("canonical");
|
|
t.file("sub/a.CR2", b"x");
|
|
let p = FolderProvider::new();
|
|
let direct = p
|
|
.normalise_endpoint(&t.0.join("sub").to_string_lossy())
|
|
.unwrap();
|
|
let roundabout = p
|
|
.normalise_endpoint(&t.0.join("sub/../sub").to_string_lossy())
|
|
.unwrap();
|
|
assert_eq!(direct, roundabout);
|
|
}
|
|
|
|
#[test]
|
|
fn a_folder_account_needs_no_credential() {
|
|
let p = FolderProvider::new();
|
|
assert_eq!(p.sign_in(), SignIn::EndpointOnly);
|
|
assert!(!p.sign_in().needs_secret());
|
|
|
|
let account = p.account_for("/mnt/photos").unwrap();
|
|
assert_eq!(account.backend, BACKEND_ID);
|
|
assert_eq!(account.endpoint, "/mnt/photos");
|
|
assert!(account.login.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_registry_opens_a_folder_account() {
|
|
// End to end through the abstraction: an account, a registry, a backend —
|
|
// with nothing in between naming this crate.
|
|
let t = Tmp::new("registry");
|
|
let mut registry = dr_sync::BackendRegistry::new();
|
|
registry.register(std::sync::Arc::new(FolderProvider::new()));
|
|
|
|
let account = Account::new(BACKEND_ID, t.0.to_string_lossy());
|
|
let backend = registry.connect(&Connection::new(account, None)).unwrap();
|
|
assert_eq!(backend.name(), "Folder");
|
|
}
|
|
|
|
// --- virtual filesystems --------------------------------------------------
|
|
//
|
|
// A suffix-mode convention, matching the only one Linux supports. The
|
|
// behaviour under test is what the *backend* does with it; the borrow cycle
|
|
// has its own tests beside the pool.
|
|
|
|
struct SuffixVfs;
|
|
|
|
impl Vfs for SuffixVfs {
|
|
fn name(&self) -> &'static str {
|
|
"suffix"
|
|
}
|
|
fn is_placeholder(&self, on_disk: &str) -> bool {
|
|
on_disk.ends_with(".stub")
|
|
}
|
|
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
|
|
on_disk.strip_suffix(".stub").unwrap_or(on_disk)
|
|
}
|
|
fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> {
|
|
std::borrow::Cow::Owned(format!("{name}.stub"))
|
|
}
|
|
}
|
|
|
|
fn with_stubs(t: &Tmp) -> FolderBackend {
|
|
FolderBackend::with_vfs(&t.0, std::sync::Arc::new(SuffixVfs)).unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_placeholder_is_listed_under_the_photographs_own_name() {
|
|
// The catalog records this as `source_ref`, and identity is derived from
|
|
// it. Reporting the stub's name gives the same photograph two identities
|
|
// and a name no other device recognises.
|
|
let t = Tmp::new("vfs-name");
|
|
t.file("shoot/IMG_0001.CR2.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
let entries = b.list(&RemotePath::new("shoot"), None).await.unwrap();
|
|
assert_eq!(entries[0].path.as_str(), "shoot/IMG_0001.CR2");
|
|
assert!(!entries[0].materialised, "the content is not here");
|
|
// One byte is not the photograph's size, and putting it in the catalog
|
|
// would claim a 30 MB RAW is a single byte.
|
|
assert_eq!(entries[0].size, 0, "unknown, not one");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn identity_survives_a_download() {
|
|
// The failure this prevents: downloading a photograph looked like a
|
|
// delete and an add, which orphaned its thumbnail and its face rows.
|
|
let t = Tmp::new("vfs-identity");
|
|
t.file("a.CR2.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
let before = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
std::fs::remove_file(t.0.join("a.CR2.stub")).unwrap();
|
|
std::fs::write(t.0.join("a.CR2"), vec![3u8; 4096]).unwrap();
|
|
let after = b.list(&RemotePath::root(), None).await.unwrap()[0]
|
|
.id
|
|
.clone();
|
|
|
|
assert_eq!(before, after, "the same photograph throughout");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reading_a_placeholder_is_distinguishable_from_a_missing_file() {
|
|
// The distinction the sidecar writer depends on: "not here" is fetchable
|
|
// and "not found" means create a new one. Conflating them overwrites an
|
|
// existing sidecar with a fresh document.
|
|
let t = Tmp::new("vfs-read");
|
|
t.file("a.drsc.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
let stub = b
|
|
.get(&RemoteId::Path(RemotePath::new("a.drsc")), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(stub, RemoteError::NotMaterialised(_)), "{stub:?}");
|
|
|
|
let absent = b
|
|
.get(&RemoteId::Path(RemotePath::new("nothing.drsc")), None)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(absent, RemoteError::NotFound(_)), "{absent:?}");
|
|
|
|
// And emphatically not the stub's one byte, which is what made a
|
|
// dehydrated sidecar parse as an empty document.
|
|
assert!(!matches!(stub, RemoteError::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unconditional_write_replaces_a_placeholder() {
|
|
// Derived state — shards, the catalog snapshot — lives in the library
|
|
// folder, so the client dehydrates it like anything else. Refusing here
|
|
// meant sync could never write to a folder it had been away from. The
|
|
// whole file is being replaced, so there is nothing in the stub to keep.
|
|
let t = Tmp::new("vfs-write");
|
|
t.file("a.drsc.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
b.put(&RemotePath::new("a.drsc"), b"<new/>".to_vec(), None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(std::fs::read(t.0.join("a.drsc")).unwrap(), b"<new/>");
|
|
// And exactly one file for one document: a leftover stub beside it is a
|
|
// conflict the client would resolve in favour of whichever it saw last.
|
|
assert!(!t.0.join("a.drsc.stub").exists(), "placeholder left behind");
|
|
assert_eq!(
|
|
names(&b.list(&RemotePath::root(), None).await.unwrap()),
|
|
vec!["a.drsc"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_conditional_write_over_a_placeholder_asks_for_the_content_first() {
|
|
// `IfMatch` guards a read-modify-write. A stub's validator describes the
|
|
// placeholder, not the document, so nothing here can satisfy it — and
|
|
// quietly writing anyway is how the other device's edits are lost.
|
|
let t = Tmp::new("vfs-write-cond");
|
|
t.file("a.drsc.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
let e = b
|
|
.put(
|
|
&RemotePath::new("a.drsc"),
|
|
b"<new/>".to_vec(),
|
|
Some(Precondition::IfMatch(Validator::new("whatever"))),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::NotMaterialised(_)), "{e:?}");
|
|
assert!(!t.0.join("a.drsc").exists(), "nothing written");
|
|
|
|
// And a create-if-absent fails, because the file *is* there — only its
|
|
// content is elsewhere.
|
|
let e = b
|
|
.put(
|
|
&RemotePath::new("a.drsc"),
|
|
b"<new/>".to_vec(),
|
|
Some(Precondition::IfAbsent),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(e, RemoteError::PreconditionFailed), "{e:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn trashing_a_photograph_that_is_not_downloaded_moves_the_placeholder() {
|
|
// Culling without downloading is the ordinary way to use a VFS library.
|
|
// The stub has to move, and has to stay a stub — leaving it behind means
|
|
// the next scan re-lists the image and undoes the delete.
|
|
let t = Tmp::new("vfs-trash");
|
|
t.file("a.CR2.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
b.move_to(
|
|
&RemoteId::Path(RemotePath::new("a.CR2")),
|
|
&RemotePath::new(".darkroom-trash/a.CR2"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!t.0.join("a.CR2.stub").exists());
|
|
assert!(
|
|
t.0.join(".darkroom-trash/a.CR2.stub").is_file(),
|
|
"still a stub"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_folder_without_a_client_still_lists_and_reads_what_is_there() {
|
|
// No hydration available is a degraded mode, not a broken one: the
|
|
// materialised half of the library works completely.
|
|
let t = Tmp::new("vfs-degraded");
|
|
t.file("here.CR2", b"real").file("gone.CR2.stub", &[0u8]);
|
|
let b = with_stubs(&t);
|
|
|
|
assert_eq!(
|
|
b.capabilities().materialisation,
|
|
dr_sync::Materialisation::Placeholders,
|
|
"stubs exist and nothing can fetch them"
|
|
);
|
|
assert!(!b.capabilities().materialisation.can_materialise());
|
|
|
|
let got = b
|
|
.get(&RemoteId::Path(RemotePath::new("here.CR2")), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(got, b"real");
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_folder_reports_that_everything_it_lists_is_readable() {
|
|
let t = Tmp::new("vfs-plain");
|
|
assert_eq!(
|
|
t.backend().capabilities().materialisation,
|
|
dr_sync::Materialisation::Always
|
|
);
|
|
}
|