Re-inserting a card that has already been imported produced a folder full of `-1` copies. Both halves of the placement logic treated a taken name as a collision to rename around, which is right for the case they were written for — two cameras both writing IMG_0001.CR3 — and exactly wrong for the far more common one, where the taken name is the same photograph. Locally this cannot be answered from the catalog. On a library whose catalog describes a *server*, a file sitting in the local destination has no row to be found by, so the only way to know whether it has already been copied is to look. The destination folder is listed once per folder rather than probed per file: a card is two thousand frames landing in a handful of days. Remotely the same question is one PROPFIND that was already being made to resolve the name, so `upload_original` now returns `Placed::AlreadyThere` instead of inventing a second copy of work that is already safe. The count is reported apart from `uploaded`, because "12 already on the server" and "12 uploaded" are different answers to whether this run backed anything up — and apart from the local duplicate count, because these files *were* copied here. Name plus length decides it, not a digest: this runs before any transfer, and hashing to answer it would read the whole card to avoid reading the whole card. A camera reusing a filename after IMG_9999 writes a different number of bytes essentially always, which leaves the rename for the case it is really for. The digest tier still catches the same frame under a different name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
394 lines
14 KiB
Rust
394 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,
|
|
})
|
|
.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 { .. }));
|
|
}
|
|
}
|