Files
DarkRoom/core/dr-sync/src/upload.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

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 { .. }));
}
}