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:
2026-08-29 09:57:52 +02:00
parent 6c363cee97
commit c102ba9df2
22 changed files with 1555 additions and 53 deletions
@@ -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);
}
}