//! 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, ) -> Result { 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 { let taken: std::collections::HashMap = 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 { 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, 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 { Err(RemoteError::Unsupported("test backend")) } async fn delta( &self, _cursor: &crate::Cursor, ) -> Result<(Vec, crate::Cursor), RemoteError> { Err(RemoteError::Unsupported("test backend")) } async fn get( &self, _id: &RemoteId, _range: Option>, ) -> Result, RemoteError> { Err(RemoteError::Unsupported("test backend")) } async fn put( &self, _path: &RemotePath, _body: Vec, _precond: Option, ) -> Result { Ok(Validator::new("v")) } async fn delete( &self, _id: &RemoteId, _precond: Option, ) -> 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: 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 { .. })); } }