Treat a placeholder as the photograph, not as a one-byte file
The folder connector was pointed at a Nextcloud VFS tree and got three things wrong, the first of which loses work. **A dehydrated sidecar read as absent.** `a.drsc` does not exist when the client has dehydrated it — only `a.drsc.nextcloud` does — so `get` missed, `.ok()` swallowed the `NotFound`, and the sidecar writer took that for "there is no sidecar yet" and wrote a fresh document over the existing one. Every edit another device had put there went with it. That function's own doc comment calls this the exact loss the format's unknown-key preservation exists to prevent. **A stub was catalogued as a 1-byte image**, and ARCH §9.0 measured this machine at 121,785 placeholders against 10,267 real files — so a folder library on a synced tree was ~92% broken rows. **Identity changed on hydration**, so downloading a photograph looked like a delete and an add, orphaning its thumbnail and its face rows. Entries now carry the photograph's own name and a `materialised` flag; `get` on a stub returns the new `RemoteError::NotMaterialised`, which is distinct from `NotFound` precisely because the sidecar writer must treat them differently — it fetches the sidecar and merges, or leaves the entry queued. Hydration is a **borrow**. `BorrowPool` records what was on disk before it asked, so `release_all` dehydrates only what a pass brought and leaves what the user already had. Reference counted: the thumbnail pass and the face pass meet on the same RAW, and without counting the first to finish dehydrates the file the second is reading. A borrow against a plain folder or a server does nothing, so a pass written for VFS runs everywhere. Releasing means asking the client to dehydrate and never deleting: a deletion inside a synced tree propagates to the server and removes the photograph from every device. Not a second backend — the capability is per *connection*, not per type, since the same folder hydrates only while the client runs. The convention arrives through a detector the registry supplies, so `dr-sync-folder` still knows nothing about any client's protocol. ARCH §9.0a records this as an amendment: finding 3 rejected hydration because it costs 100× a range read, and that comparison assumed a connector was available. A folder library has none.
This commit is contained in:
@@ -163,3 +163,138 @@ mod tests {
|
||||
let _ = DesktopClient::detect();
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-6c
|
||||
/// The desktop client's placeholder convention, as a
|
||||
/// [`Vfs`](dr_sync_folder::Vfs).
|
||||
///
|
||||
/// This is what turns a folder the client syncs into a library DarkRoom can
|
||||
/// open: the folder connector handles every filesystem operation, and this
|
||||
/// answers the three questions it cannot — what is a stub, what is the
|
||||
/// photograph called, and can the content be fetched and given back.
|
||||
///
|
||||
/// **Linux suffix mode only**, which is the only mode Linux supports
|
||||
/// (ARCH §9.0). A dehydrated `IMG.CR2` exists solely as `IMG.CR2.nextcloud`
|
||||
/// holding one byte.
|
||||
pub struct NextcloudVfs {
|
||||
/// `None` where no client is running. The folder still lists and reads
|
||||
/// correctly; it simply cannot fetch what is not there, which the backend
|
||||
/// reports as `Materialisation::Placeholders`.
|
||||
client: Option<DesktopClient>,
|
||||
}
|
||||
|
||||
impl NextcloudVfs {
|
||||
/// Attach to a running client, if there is one.
|
||||
///
|
||||
/// Absence is the ordinary state — Android always, desktop whenever the
|
||||
/// client is not running — and never an error.
|
||||
pub fn detect() -> Self {
|
||||
Self {
|
||||
client: DesktopClient::detect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a directory looks like one this client syncs.
|
||||
///
|
||||
/// Used to decide whether to apply this convention at all. Deliberately
|
||||
/// cheap and deliberately not authoritative: the client's own database
|
||||
/// would answer properly, but it is a private schema, and being wrong here
|
||||
/// costs one extra `stat` per read rather than anything correctness
|
||||
/// depends on.
|
||||
pub fn looks_synced(root: &Path) -> bool {
|
||||
std::fs::read_dir(root)
|
||||
.map(|entries| {
|
||||
entries.flatten().any(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// The client's per-folder journal sits at the sync root,
|
||||
// and a stub anywhere beneath it is equally conclusive.
|
||||
name.starts_with("._sync_") && name.ends_with(".db")
|
||||
|| name.ends_with(dr_types::PLACEHOLDER_SUFFIX)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl dr_sync_folder::Vfs for NextcloudVfs {
|
||||
fn name(&self) -> &'static str {
|
||||
"Nextcloud"
|
||||
}
|
||||
|
||||
fn is_placeholder(&self, on_disk: &str) -> bool {
|
||||
on_disk.ends_with(dr_types::PLACEHOLDER_SUFFIX)
|
||||
}
|
||||
|
||||
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
|
||||
on_disk
|
||||
.strip_suffix(dr_types::PLACEHOLDER_SUFFIX)
|
||||
.unwrap_or(on_disk)
|
||||
}
|
||||
|
||||
fn placeholder_name(&self, name: &str) -> std::borrow::Cow<'_, str> {
|
||||
std::borrow::Cow::Owned(format!("{name}{}", dr_types::PLACEHOLDER_SUFFIX))
|
||||
}
|
||||
|
||||
fn can_materialise(&self) -> bool {
|
||||
self.client.is_some()
|
||||
}
|
||||
|
||||
fn materialise(&self, local: &Path) -> Result<(), RemoteError> {
|
||||
self.client
|
||||
.as_ref()
|
||||
.ok_or(RemoteError::Unsupported(
|
||||
"no Nextcloud desktop client is running to fetch this",
|
||||
))?
|
||||
.make_available_locally(local)
|
||||
}
|
||||
|
||||
fn dematerialise(&self, local: &Path) -> Result<(), RemoteError> {
|
||||
self.client
|
||||
.as_ref()
|
||||
.ok_or(RemoteError::Unsupported(
|
||||
"no Nextcloud desktop client is running to release this",
|
||||
))?
|
||||
.make_online_only(local)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod vfs_tests {
|
||||
use super::*;
|
||||
use dr_sync_folder::Vfs as _;
|
||||
|
||||
#[test]
|
||||
fn a_stub_is_recognised_and_reports_the_photographs_name() {
|
||||
let v = NextcloudVfs { client: None };
|
||||
assert!(v.is_placeholder("IMG_4130.CR2.nextcloud"));
|
||||
assert!(!v.is_placeholder("IMG_4130.CR2"));
|
||||
// The name the catalog records, so identity survives a download.
|
||||
assert_eq!(v.real_name("IMG_4130.CR2.nextcloud"), "IMG_4130.CR2");
|
||||
assert_eq!(v.real_name("IMG_4130.CR2"), "IMG_4130.CR2");
|
||||
assert_eq!(v.placeholder_name("IMG_4130.CR2"), "IMG_4130.CR2.nextcloud");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_client_it_refuses_rather_than_pretending() {
|
||||
// The folder still works; it just cannot fetch. Silently doing nothing
|
||||
// would make a borrow think it had the content.
|
||||
let v = NextcloudVfs { client: None };
|
||||
assert!(!v.can_materialise());
|
||||
assert!(v.materialise(Path::new("/x/a.CR2.nextcloud")).is_err());
|
||||
assert!(v.dematerialise(Path::new("/x/a.CR2")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_folder_is_not_mistaken_for_a_synced_one() {
|
||||
let d = std::env::temp_dir().join("dr-vfs-detect");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("a.CR2"), b"raw").unwrap();
|
||||
assert!(!NextcloudVfs::looks_synced(&d));
|
||||
|
||||
std::fs::write(d.join("b.CR2.nextcloud"), [0u8]).unwrap();
|
||||
assert!(NextcloudVfs::looks_synced(&d));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user