//! 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 { let mut v: Vec = 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"".to_vec(), None) .await .unwrap(); assert_eq!(std::fs::read(t.0.join("2026/03/a.xmp")).unwrap(), b""); } #[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"".to_vec(), None) .await .unwrap(); assert_eq!(std::fs::read(t.0.join("a.drsc")).unwrap(), b""); // 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"".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"".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 ); }