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.
395 lines
14 KiB
Rust
395 lines
14 KiB
Rust
//! TRACES: FR-NC-7a | FR-NC-7b
|
|
//! Where an uploaded original lands on the server.
|
|
//!
|
|
//! FR-NC-7 says how the bytes travel — chunked upload v2, `MKCOL`, `MOVE` of
|
|
//! the `.file` pseudo-entry. This says *where they go*, which the transport
|
|
//! deliberately has no opinion about.
|
|
//!
|
|
//! # The template is expanded on the ingest side, not here
|
|
//!
|
|
//! An upload receives folder *segments*, already expanded and already checked
|
|
//! to be ordinary names (`dr_ingest::layout`). That split is what makes
|
|
//! FR-NC-7a's first consequence true: the destination is a pure function of
|
|
//! capture metadata and the template, so two devices uploading the same frame
|
|
//! compute the same path. If this module re-derived the folders from whatever
|
|
//! the local library happened to look like, two machines with differently
|
|
//! organised libraries would file the same photograph in two different places
|
|
//! on one server.
|
|
//!
|
|
//! # What this deliberately does not do
|
|
//!
|
|
//! It does not move anything already on the server. FR-NC-7a's second
|
|
//! consequence: the template governs placement *on upload only*, because the
|
|
//! remote library is reachable by other clients and restructuring it under
|
|
//! them is not ours to do.
|
|
|
|
use crate::{EntryKind, RemoteBackend, RemoteError, RemotePath, Validator};
|
|
|
|
/// The folder an original belongs in, under a library root.
|
|
///
|
|
/// Segments are joined one at a time rather than formatted into a string,
|
|
/// because [`RemotePath::join`] is what normalises separators — a segment
|
|
/// arriving with a stray slash must not silently become two levels.
|
|
pub fn destination(library: &RemotePath, folders: &[String]) -> RemotePath {
|
|
folders
|
|
.iter()
|
|
.filter(|s| !s.is_empty())
|
|
.fold(library.clone(), |acc, seg| acc.join(seg))
|
|
}
|
|
|
|
/// TRACES: FR-NC-7a | FR-CAT-11
|
|
/// What became of an original the caller offered.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Placed {
|
|
/// Sent now.
|
|
Uploaded {
|
|
path: RemotePath,
|
|
validator: Validator,
|
|
},
|
|
/// The server already held this photograph, so nothing was transferred.
|
|
///
|
|
/// The case a re-inserted card produces: the shoot was imported and
|
|
/// uploaded last week, and the card has not been formatted since. Worth a
|
|
/// variant of its own rather than folding into `Uploaded`, because the two
|
|
/// are different answers to "is my work backed up" — and because a caller
|
|
/// counting bytes moved must not count these.
|
|
AlreadyThere { path: RemotePath },
|
|
}
|
|
|
|
impl Placed {
|
|
/// Where the photograph is on the server, however it got there.
|
|
pub fn path(&self) -> &RemotePath {
|
|
match self {
|
|
Placed::Uploaded { path, .. } | Placed::AlreadyThere { path } => path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Upload one original into its dated folder, unless it is already there.
|
|
///
|
|
/// Creates the folders if they are missing — `create_dir` makes parents and
|
|
/// succeeds on one that already exists, so the second import of a day costs
|
|
/// one request rather than a conflict.
|
|
///
|
|
/// Returns where the photograph is, which may not be the name that was asked
|
|
/// for: two cameras produce `IMG_0001.CR3` and the second must not overwrite
|
|
/// the first. The caller records the returned path, never the one it passed.
|
|
///
|
|
/// `size` is what decides between "already there" and "a different photograph
|
|
/// with the same name". See [`resolve`].
|
|
pub async fn upload_original(
|
|
backend: &dyn RemoteBackend,
|
|
library: &RemotePath,
|
|
folders: &[String],
|
|
name: &str,
|
|
body: Vec<u8>,
|
|
) -> Result<Placed, RemoteError> {
|
|
let dir = destination(library, folders);
|
|
backend.create_dir(&dir).await?;
|
|
|
|
match resolve(backend, &dir, name, body.len() as u64).await? {
|
|
Resolution::AlreadyThere => Ok(Placed::AlreadyThere {
|
|
path: dir.join(name),
|
|
}),
|
|
Resolution::Use(name) => {
|
|
let path = dir.join(&name);
|
|
let validator = backend.put(&path, body, None).await?;
|
|
Ok(Placed::Uploaded { path, validator })
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What to do about a name in the destination folder.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Resolution {
|
|
/// Write under this name.
|
|
Use(String),
|
|
/// The photograph is already on the server. Send nothing.
|
|
AlreadyThere,
|
|
}
|
|
|
|
/// Decide what to call an original in a remote folder, or that it is already
|
|
/// there.
|
|
///
|
|
/// One listing rather than a probe per candidate: a day folder is listed in a
|
|
/// single `PROPFIND` and the answer covers every collision in it, where
|
|
/// probing would be a round trip per attempt over a link that may be mobile
|
|
/// data.
|
|
///
|
|
/// **Name plus length decides "already there".** A card that was imported and
|
|
/// uploaded last week and has not been formatted since is the ordinary case,
|
|
/// and re-uploading it would double the storage and fill the folder with `-1`
|
|
/// copies of a shoot that is already safe. The same length under the same name
|
|
/// in the folder this photograph belongs in is that photograph in every case
|
|
/// that is not deliberately constructed — and a camera that reuses a filename
|
|
/// after `IMG_9999` writes a *different* number of bytes essentially always,
|
|
/// which is what leaves the rename below for the case it is actually for.
|
|
///
|
|
/// A folder that cannot be listed yields the original name rather than an
|
|
/// error. That is the safe direction on a server that has just been asked to
|
|
/// create the folder: the alternative is failing an upload because a listing
|
|
/// raced the `MKCOL`.
|
|
async fn resolve(
|
|
backend: &dyn RemoteBackend,
|
|
dir: &RemotePath,
|
|
name: &str,
|
|
size: u64,
|
|
) -> Result<Resolution, RemoteError> {
|
|
let taken: std::collections::HashMap<String, u64> = match backend.list(dir, None).await {
|
|
Ok(entries) => entries
|
|
.into_iter()
|
|
.filter(|e| e.kind == EntryKind::File)
|
|
.map(|e| (e.path.name().to_string(), e.size))
|
|
.collect(),
|
|
Err(RemoteError::NotFound(_)) => Default::default(),
|
|
Err(e) => return Err(e),
|
|
};
|
|
|
|
match taken.get(name) {
|
|
None => return Ok(Resolution::Use(name.to_string())),
|
|
Some(&there) if there == size => return Ok(Resolution::AlreadyThere),
|
|
Some(_) => {}
|
|
}
|
|
|
|
// A different photograph that happens to share a name. The same suffix
|
|
// convention the local import uses, so one that collided on both sides
|
|
// carries the same name in both places.
|
|
let (stem, ext) = match name.rsplit_once('.') {
|
|
Some((s, e)) if !s.is_empty() => (s, Some(e)),
|
|
_ => (name, None),
|
|
};
|
|
for n in 1..1000 {
|
|
let candidate = match ext {
|
|
Some(ext) => format!("{stem}-{n}.{ext}"),
|
|
None => format!("{stem}-{n}"),
|
|
};
|
|
match taken.get(&candidate) {
|
|
None => return Ok(Resolution::Use(candidate)),
|
|
// The renamed copy is itself already up there: this is the second
|
|
// re-import of a card that legitimately collided once.
|
|
Some(&there) if there == size => return Ok(Resolution::AlreadyThere),
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
Err(RemoteError::Unsupported(
|
|
"a thousand files of the same name in one folder",
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::{RemoteEntry, RemoteId};
|
|
|
|
fn segs(v: &[&str]) -> Vec<String> {
|
|
v.iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn an_original_lands_under_year_then_day() {
|
|
let dest = destination(
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
);
|
|
assert_eq!(dest.as_str(), "PhotosRaw/2026/2026-08-22");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_segment_does_not_leave_a_nameless_folder() {
|
|
let dest = destination(&RemotePath::new("PhotosRaw"), &segs(&["2026", "", "x"]));
|
|
assert_eq!(dest.as_str(), "PhotosRaw/2026/x");
|
|
}
|
|
|
|
#[test]
|
|
fn a_library_at_the_root_still_works() {
|
|
let dest = destination(&RemotePath::root(), &segs(&["2026", "2026-08-22"]));
|
|
assert_eq!(dest.as_str(), "2026/2026-08-22");
|
|
}
|
|
|
|
// The upload path itself needs a backend, which is exercised against a
|
|
// fake in `dr-sync-nextcloud`'s tests and against a real server in S8.
|
|
// What is worth pinning here is the collision rule, since it decides
|
|
// whether a second camera's `IMG_0001.CR3` overwrites the first.
|
|
/// A folder holding files of given name and length.
|
|
struct Listing(Vec<(&'static str, u64)>);
|
|
|
|
#[async_trait::async_trait]
|
|
impl RemoteBackend for Listing {
|
|
fn capabilities(&self) -> &crate::Capabilities {
|
|
unimplemented!("not reached by the collision tests")
|
|
}
|
|
fn name(&self) -> &str {
|
|
"listing"
|
|
}
|
|
async fn list(
|
|
&self,
|
|
dir: &RemotePath,
|
|
_since: Option<&Validator>,
|
|
) -> Result<Vec<RemoteEntry>, RemoteError> {
|
|
Ok(self
|
|
.0
|
|
.iter()
|
|
.map(|(n, size)| RemoteEntry {
|
|
id: RemoteId::Path(dir.join(n)),
|
|
path: dir.join(n),
|
|
kind: EntryKind::File,
|
|
validator: Validator::new("v"),
|
|
size: *size,
|
|
modified: None,
|
|
has_preview: false,
|
|
materialised: true,
|
|
})
|
|
.collect())
|
|
}
|
|
async fn dir_validator(&self, _dir: &RemotePath) -> Result<Validator, RemoteError> {
|
|
Err(RemoteError::Unsupported("test backend"))
|
|
}
|
|
async fn delta(
|
|
&self,
|
|
_cursor: &crate::Cursor,
|
|
) -> Result<(Vec<crate::RemoteChange>, crate::Cursor), RemoteError> {
|
|
Err(RemoteError::Unsupported("test backend"))
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_id: &RemoteId,
|
|
_range: Option<std::ops::Range<u64>>,
|
|
) -> Result<Vec<u8>, RemoteError> {
|
|
Err(RemoteError::Unsupported("test backend"))
|
|
}
|
|
async fn put(
|
|
&self,
|
|
_path: &RemotePath,
|
|
_body: Vec<u8>,
|
|
_precond: Option<crate::Precondition>,
|
|
) -> Result<Validator, RemoteError> {
|
|
Ok(Validator::new("v"))
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_id: &RemoteId,
|
|
_precond: Option<crate::Precondition>,
|
|
) -> Result<(), RemoteError> {
|
|
Err(RemoteError::Unsupported("test backend"))
|
|
}
|
|
async fn move_to(&self, _from: &RemoteId, _to: &RemotePath) -> Result<(), RemoteError> {
|
|
Err(RemoteError::Unsupported("test backend"))
|
|
}
|
|
async fn create_dir(&self, _path: &RemotePath) -> Result<(), RemoteError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Poll a future to completion on this thread.
|
|
///
|
|
/// A hand-rolled poll rather than a runtime dependency for two tests: the
|
|
/// fake backend answers immediately, so a future that came back pending
|
|
/// would mean the fake had grown a suspension the tests do not model.
|
|
fn block_on<F: std::future::Future>(f: F) -> F::Output {
|
|
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
|
|
fn noop(_: *const ()) {}
|
|
fn clone(p: *const ()) -> RawWaker {
|
|
RawWaker::new(p, &VTABLE)
|
|
}
|
|
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
|
|
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
|
|
let mut cx = Context::from_waker(&waker);
|
|
let mut f = std::pin::pin!(f);
|
|
match f.as_mut().poll(&mut cx) {
|
|
Poll::Ready(v) => v,
|
|
Poll::Pending => panic!("the fake backend never yields"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_card_already_on_the_server_transfers_nothing() {
|
|
// The ordinary case: last week's shoot was imported and uploaded, and
|
|
// the card has not been formatted since. Re-uploading would double the
|
|
// storage and litter the folder with `-1` copies of safe work.
|
|
let backend = Listing(vec![("IMG_0001.CR3", 3)]);
|
|
let placed = block_on(upload_original(
|
|
&backend,
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
"IMG_0001.CR3",
|
|
vec![1, 2, 3],
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
placed,
|
|
Placed::AlreadyThere {
|
|
path: RemotePath::new("PhotosRaw/2026/2026-08-22/IMG_0001.CR3")
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_cameras_img_0001_does_not_overwrite_the_first() {
|
|
// Same name, different length: a genuinely different photograph, which
|
|
// is what the rename is for.
|
|
let backend = Listing(vec![("IMG_0001.CR3", 99)]);
|
|
let placed = block_on(upload_original(
|
|
&backend,
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
"IMG_0001.CR3",
|
|
vec![1, 2, 3],
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
placed.path().as_str(),
|
|
"PhotosRaw/2026/2026-08-22/IMG_0001-1.CR3"
|
|
);
|
|
assert!(matches!(placed, Placed::Uploaded { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn a_free_name_is_used_as_it_is() {
|
|
let backend = Listing(vec![("IMG_0002.CR3", 3)]);
|
|
let placed = block_on(upload_original(
|
|
&backend,
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
"IMG_0001.CR3",
|
|
vec![1, 2, 3],
|
|
))
|
|
.unwrap();
|
|
assert_eq!(
|
|
placed.path().as_str(),
|
|
"PhotosRaw/2026/2026-08-22/IMG_0001.CR3"
|
|
);
|
|
assert!(matches!(placed, Placed::Uploaded { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn a_renamed_copy_that_is_already_up_there_is_not_sent_twice() {
|
|
// The card that collided once and is now being re-imported: both the
|
|
// original name and the renamed copy are on the server, and this
|
|
// photograph is the renamed one.
|
|
let backend = Listing(vec![("IMG_0001.CR3", 99), ("IMG_0001-1.CR3", 3)]);
|
|
let placed = block_on(upload_original(
|
|
&backend,
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
"IMG_0001.CR3",
|
|
vec![1, 2, 3],
|
|
))
|
|
.unwrap();
|
|
assert!(matches!(placed, Placed::AlreadyThere { .. }), "{placed:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_folder_takes_the_name_as_offered() {
|
|
let backend = Listing(vec![]);
|
|
let placed = block_on(upload_original(
|
|
&backend,
|
|
&RemotePath::new("PhotosRaw"),
|
|
&segs(&["2026", "2026-08-22"]),
|
|
"IMG_0001.CR3",
|
|
vec![1, 2, 3],
|
|
))
|
|
.unwrap();
|
|
assert!(matches!(placed, Placed::Uploaded { .. }));
|
|
}
|
|
}
|