Files
DarkRoom/core/dr-sync-folder/src/vfs.rs
T
dtourolle c102ba9df2 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.
2026-08-29 09:57:52 +02:00

132 lines
5.2 KiB
Rust

// TRACES: FR-NC-6c
//! Virtual-filesystem conventions layered over a directory.
//!
//! A sync client in virtual-files mode leaves a *placeholder* where a file is
//! catalogued but not downloaded. The folder is otherwise ordinary, so all of
//! [`FolderBackend`](crate::FolderBackend) applies — only three questions
//! differ, and they are the whole of this trait: what is a placeholder, what
//! is the photograph really called, and can the content be summoned.
//!
//! # Why this is not a separate backend
//!
//! It varies nothing about listing, reading, writing, moving or deleting — a
//! second connector would duplicate every one of those to change a name test.
//! More decisively, **the interesting capability is not a property of the
//! backend at all**: the same folder can materialise on demand while the sync
//! client is running and cannot when it is not, so it has to be computed per
//! connection either way. Registering a `folder-vfs` provider beside `folder`
//! would ask the user to choose between two things that differ by whether a
//! background process happens to be up.
//!
//! # Why the plain case is a `Vfs` too
//!
//! [`NoVfs`] answers "nothing is a placeholder" and refuses to materialise.
//! That keeps one code path through the backend rather than an `Option` tested
//! at every call site, and it is the shape a third convention — Dropbox,
//! OneDrive, macOS FileProvider — slots into.
//!
//! # What is *not* abstracted here
//!
//! Windows and macOS express placeholders in filesystem metadata rather than
//! in the name: a reparse point, or `st_blocks == 0` against a non-zero
//! `st_size`. That form needs a `Metadata` to answer, not a name, and the one
//! convention this project has met needs only a name. Widening the trait for a
//! platform nobody has run this on would be guessing at the shape.
use std::borrow::Cow;
use std::path::Path;
use dr_sync::RemoteError;
/// A placeholder convention, and what can be done about it.
///
/// Implementations are held behind an `Arc` and used from every worker
/// thread.
pub trait Vfs: Send + Sync {
/// A name for logs and the interface. "none", "Nextcloud".
fn name(&self) -> &'static str;
/// Whether a name **on disk** stands for content that is not here.
fn is_placeholder(&self, on_disk: &str) -> bool;
/// The photograph's own name, given whatever is on disk.
///
/// This is what the catalog records and what identity is derived from, so
/// a file keeps one name and one id across being downloaded and released.
/// Reporting the on-disk name instead makes hydration look like a delete
/// and an add.
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str;
/// What a placeholder for `name` would be called on disk.
fn placeholder_name(&self, name: &str) -> Cow<'_, str>;
/// Whether content can actually be summoned right now.
///
/// False where the mechanism is absent — the client is not running, the
/// platform has no socket — which is an ordinary state and not an error.
/// The backend reports [`Materialisation::Placeholders`] rather than
/// [`OnDemand`] when this is false.
///
/// [`Materialisation::Placeholders`]: dr_sync::Materialisation::Placeholders
/// [`OnDemand`]: dr_sync::Materialisation::OnDemand
fn can_materialise(&self) -> bool {
false
}
/// Ask for a placeholder's content. Whole-file and slow.
fn materialise(&self, _local: &Path) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported("this folder has no VFS client"))
}
/// Give the content back, leaving a placeholder.
///
/// **Must not delete.** In a synced tree a deletion propagates to the
/// server and removes the photograph from every device. An implementation
/// that cannot dehydrate returns `Unsupported`.
fn dematerialise(&self, _local: &Path) -> Result<(), RemoteError> {
Err(RemoteError::Unsupported("this folder has no VFS client"))
}
}
/// An ordinary directory: every file is what it appears to be.
#[derive(Debug, Clone, Copy, Default)]
pub struct NoVfs;
impl Vfs for NoVfs {
fn name(&self) -> &'static str {
"none"
}
fn is_placeholder(&self, _on_disk: &str) -> bool {
false
}
fn real_name<'a>(&self, on_disk: &'a str) -> &'a str {
on_disk
}
fn placeholder_name(&self, name: &str) -> Cow<'_, str> {
// Nothing is ever a placeholder here, so the only honest answer is
// the name itself — the backend will look for it, not find a second
// candidate, and report the file missing.
Cow::Owned(name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_folder_has_no_placeholders_and_cannot_summon_anything() {
let v = NoVfs;
assert!(
!v.is_placeholder("IMG.CR2.nextcloud"),
"not this folder's convention"
);
assert_eq!(v.real_name("IMG.CR2"), "IMG.CR2");
assert!(!v.can_materialise());
assert!(v.materialise(Path::new("/x")).is_err());
// And it must refuse rather than approximate: deleting a file to
// "dehydrate" it would remove the photograph.
assert!(v.dematerialise(Path::new("/x")).is_err());
}
}