fix(nc/webdav): honour Overwrite on MOVE; restore-onto-existing → 412

Closes G4 / G5 / K5.

handle_move now resolves the destination once before the file/folder
dispatch and applies RFC 4918 §9.9.4:

  - Overwrite: F on a collision → 412 Precondition Failed, source
    untouched, destination untouched.
  - Overwrite: T (or absent) on a collision → delete the existing
    destination, then proceed → 204 No Content.
  - No collision → 201 Created (unchanged).

The same destination lookup powers the 201-vs-204 status decision, so
adding the precondition guard adds zero extra DB hits on the happy
path.

handle_restore now catches the unique-index collision out of
restore_item and returns 412 instead of letting it bubble as 500.
Mirrors the G4 semantics for the trashbin surface (restore has no
Overwrite header so the refusal is unconditional; client resolves by
renaming the live file first).

Sabre/DAV's CorePlugin and our test pins agreed independently — NC
clients expect this exact behavior, and the new G5b/G5c positive-case
tests guard against a regression that hard-rejected every MOVE.
This commit is contained in:
Edouard Vanbelle
2026-06-16 23:24:00 +02:00
parent 9f2ebd0758
commit 5cb01b201d
3 changed files with 157 additions and 77 deletions
+34 -9
View File
@@ -108,15 +108,40 @@ async fn handle_restore(
.as_ref()
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
trash_svc
.restore_item(&id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to restore item: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
match trash_svc.restore_item(&id, user.id).await {
Ok(()) => Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap()),
Err(e) => {
// Collision at the original path — a live file/folder is sitting
// where the trashed one wants to come back to. Mirrors the G4/G5
// semantics in webdav_handler::handle_move ("Overwrite: F to an
// existing path → 412"); restore has no Overwrite header so the
// refusal is unconditional. The caller can resolve by renaming
// or trashing the conflicting live resource first.
//
// We string-match for the unique-index / duplicate-key signature
// because restore_item currently re-wraps every storage error as
// InternalError, so the original DomainError::AlreadyExists kind
// is not propagated. A follow-up should thread the kind through
// and let this be a kind-based check.
let msg = format!("{}", e);
if msg.contains("duplicate key")
|| msg.contains("unique constraint")
|| msg.to_ascii_lowercase().contains("already exists")
{
return Ok(Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())
.unwrap());
}
Err(AppError::internal_error(format!(
"Failed to restore item: {}",
e
)))
}
}
}
// ──────────────────── DELETE (empty trash) ────────────────────