fix(webdav): make PATCH's concurrency guard a real compare-and-swap
The app-level ETag re-check before the write still left a gap between the check and the actual UPDATE for a concurrent writer to land in. Push the check into the write path itself: swap_blob_hash now takes an expected_hash and only applies the SET under the same FOR UPDATE row lock it already held, closing the race instead of just narrowing it. Adds ErrorKind::PreconditionFailed (412) for the CAS-miss path; PUT/WOPI/chunked-upload keep blind-overwrite semantics by passing None
This commit is contained in:
@@ -99,6 +99,16 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
|||||||
/// on the overwrite branch and `authz.require(caller, Create,
|
/// on the overwrite branch and `authz.require(caller, Create,
|
||||||
/// Folder|Drive(id))` on the new-file branch. Handlers just plumb
|
/// Folder|Drive(id))` on the new-file branch. Handlers just plumb
|
||||||
/// `caller_id` through — no protocol-layer authz.
|
/// `caller_id` through — no protocol-layer authz.
|
||||||
|
///
|
||||||
|
/// `expected_hash`: forwarded to
|
||||||
|
/// `FileWritePort::update_file_content_with_blob` on the overwrite
|
||||||
|
/// branch for compare-and-swap; ignored on the new-file branch
|
||||||
|
/// (nothing to compare against). Pass `None` for plain PUT/WOPI/
|
||||||
|
/// chunked-upload last-write-wins semantics; pass the pre-write
|
||||||
|
/// snapshot's content hash for PATCH, where a concurrent write
|
||||||
|
/// during the (potentially slow) splice must be rejected rather
|
||||||
|
/// than silently clobbered.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn update_file_streaming_with_perms(
|
async fn update_file_streaming_with_perms(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -107,6 +117,7 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
|
|||||||
content_type: &str,
|
content_type: &str,
|
||||||
modified_at: Option<i64>,
|
modified_at: Option<i64>,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
|
expected_hash: Option<&str>,
|
||||||
) -> Result<FileDto, DomainError>;
|
) -> Result<FileDto, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,14 @@ pub trait FileWritePort: Send + Sync + 'static {
|
|||||||
///
|
///
|
||||||
/// `caller_id` is stamped into `updated_by` alongside the
|
/// `caller_id` is stamped into `updated_by` alongside the
|
||||||
/// `updated_at` bump (§14 provenance).
|
/// `updated_at` bump (§14 provenance).
|
||||||
|
///
|
||||||
|
/// `expected_hash`: when `Some`, makes this a true compare-and-swap —
|
||||||
|
/// the write only takes effect if the row's current `blob_hash`
|
||||||
|
/// still equals it, checked and applied atomically under the same
|
||||||
|
/// row lock (no gap between check and write for a concurrent writer
|
||||||
|
/// to land in). A mismatch returns `ErrorKind::PreconditionFailed`
|
||||||
|
/// and leaves the row untouched. `None` keeps the previous
|
||||||
|
/// blind-overwrite behaviour (PUT/WOPI/chunked-upload finalize).
|
||||||
async fn update_file_content_with_blob(
|
async fn update_file_content_with_blob(
|
||||||
&self,
|
&self,
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
@@ -306,6 +314,7 @@ pub trait FileWritePort: Send + Sync + 'static {
|
|||||||
size: u64,
|
size: u64,
|
||||||
modified_at: Option<i64>,
|
modified_at: Option<i64>,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
|
expected_hash: Option<&str>,
|
||||||
) -> Result<(String, i64), DomainError>;
|
) -> Result<(String, i64), DomainError>;
|
||||||
|
|
||||||
/// Registers file metadata WITHOUT writing content to disk (write-behind).
|
/// Registers file metadata WITHOUT writing content to disk (write-behind).
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ impl FileUploadService {
|
|||||||
let file = file_read.get_file(file_id).await?;
|
let file = file_read.get_file(file_id).await?;
|
||||||
let (new_hash, updated_at) = self
|
let (new_hash, updated_at) = self
|
||||||
.file_write
|
.file_write
|
||||||
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id)
|
.update_file_content_with_blob(file_id, &blob.hash, blob.size, None, caller_id, None)
|
||||||
.await?;
|
.await?;
|
||||||
// The file maps to a different blob now — stale cached content must
|
// The file maps to a different blob now — stale cached content must
|
||||||
// never be served for the rest of its TTI window.
|
// never be served for the rest of its TTI window.
|
||||||
@@ -506,6 +506,7 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
/// member and cross-tenant PUT. See
|
/// member and cross-tenant PUT. See
|
||||||
/// `docs/plan/authz_audit/nextcloud.md` and the sibling native
|
/// `docs/plan/authz_audit/nextcloud.md` and the sibling native
|
||||||
/// `/webdav/*` handler.
|
/// `/webdav/*` handler.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn update_file_streaming_with_perms(
|
async fn update_file_streaming_with_perms(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -514,6 +515,7 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
content_type: &str,
|
content_type: &str,
|
||||||
modified_at: Option<i64>,
|
modified_at: Option<i64>,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
|
expected_hash: Option<&str>,
|
||||||
) -> Result<FileDto, DomainError> {
|
) -> Result<FileDto, DomainError> {
|
||||||
let Some(authz) = &self.authorization else {
|
let Some(authz) = &self.authorization else {
|
||||||
return Err(DomainError::internal_error(
|
return Err(DomainError::internal_error(
|
||||||
@@ -552,6 +554,7 @@ impl FileUploadUseCase for FileUploadService {
|
|||||||
blob.size,
|
blob.size,
|
||||||
modified_at,
|
modified_at,
|
||||||
caller_id,
|
caller_id,
|
||||||
|
expected_hash,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// Invalidate content cache — file content has changed.
|
// Invalidate content cache — file content has changed.
|
||||||
|
|||||||
@@ -589,6 +589,7 @@ impl FileWritePort for MockFileRepository {
|
|||||||
_size: u64,
|
_size: u64,
|
||||||
_modified_at: Option<i64>,
|
_modified_at: Option<i64>,
|
||||||
_caller_id: Uuid,
|
_caller_id: Uuid,
|
||||||
|
_expected_hash: Option<&str>,
|
||||||
) -> std::result::Result<(String, i64), DomainError> {
|
) -> std::result::Result<(String, i64), DomainError> {
|
||||||
Ok((String::new(), 0))
|
Ok((String::new(), 0))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ impl FileWritePort for StubFileWritePort {
|
|||||||
_size: u64,
|
_size: u64,
|
||||||
_modified_at: Option<i64>,
|
_modified_at: Option<i64>,
|
||||||
_caller_id: Uuid,
|
_caller_id: Uuid,
|
||||||
|
_expected_hash: Option<&str>,
|
||||||
) -> Result<(String, i64), DomainError> {
|
) -> Result<(String, i64), DomainError> {
|
||||||
Ok((String::new(), 0))
|
Ok((String::new(), 0))
|
||||||
}
|
}
|
||||||
@@ -491,6 +492,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
|||||||
Ok(FileDto::default())
|
Ok(FileDto::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn update_file_streaming_with_perms(
|
async fn update_file_streaming_with_perms(
|
||||||
&self,
|
&self,
|
||||||
_path: &str,
|
_path: &str,
|
||||||
@@ -499,6 +501,7 @@ impl FileUploadUseCase for StubFileUploadUseCase {
|
|||||||
_content_type: &str,
|
_content_type: &str,
|
||||||
_modified_at: Option<i64>,
|
_modified_at: Option<i64>,
|
||||||
_caller_id: Uuid,
|
_caller_id: Uuid,
|
||||||
|
_expected_hash: Option<&str>,
|
||||||
) -> Result<FileDto, DomainError> {
|
) -> Result<FileDto, DomainError> {
|
||||||
Ok(FileDto::default())
|
Ok(FileDto::default())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ pub enum ErrorKind {
|
|||||||
/// `AlreadyExists` (which is a uniqueness violation) so audit
|
/// `AlreadyExists` (which is a uniqueness violation) so audit
|
||||||
/// readers can tell them apart.
|
/// readers can tell them apart.
|
||||||
Conflict,
|
Conflict,
|
||||||
|
/// RFC 7232 precondition failure — a caller-supplied conditional
|
||||||
|
/// (If-Match, or an internal compare-and-swap standing in for one)
|
||||||
|
/// did not hold against the resource's current state. Maps to
|
||||||
|
/// HTTP 412. Distinct from `Conflict` (409): this is specifically
|
||||||
|
/// "the state you thought you were writing against has moved."
|
||||||
|
PreconditionFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ErrorKind {
|
impl ErrorKind {
|
||||||
@@ -58,6 +64,7 @@ impl ErrorKind {
|
|||||||
ErrorKind::DatabaseError => "Database Error",
|
ErrorKind::DatabaseError => "Database Error",
|
||||||
ErrorKind::QuotaExceeded => "Quota Exceeded",
|
ErrorKind::QuotaExceeded => "Quota Exceeded",
|
||||||
ErrorKind::Conflict => "Conflict",
|
ErrorKind::Conflict => "Conflict",
|
||||||
|
ErrorKind::PreconditionFailed => "Precondition Failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +203,17 @@ impl DomainError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a precondition-failed error (RFC 7232 / CAS mismatch)
|
||||||
|
pub fn precondition_failed<S: Into<String>>(entity_type: &'static str, message: S) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::PreconditionFailed,
|
||||||
|
entity_type,
|
||||||
|
entity_id: None,
|
||||||
|
message: message.into(),
|
||||||
|
source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a validation error
|
/// Creates a validation error
|
||||||
pub fn validation_error<S: Into<String>>(message: S) -> Self {
|
pub fn validation_error<S: Into<String>>(message: S) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ impl FileBlobWriteRepository {
|
|||||||
/// row — not the row's owner. D2 shared drives let non-owners
|
/// row — not the row's owner. D2 shared drives let non-owners
|
||||||
/// overwrite content; the previous `updated_by = f.user_id` would
|
/// overwrite content; the previous `updated_by = f.user_id` would
|
||||||
/// have silently recorded the wrong principal.
|
/// have silently recorded the wrong principal.
|
||||||
|
/// `expected_hash`, when `Some`, turns this into a real
|
||||||
|
/// compare-and-swap: the SET clause only takes effect if the row's
|
||||||
|
/// `blob_hash` still matches at the moment the `FOR UPDATE` lock is
|
||||||
|
/// held (same statement, same transaction — no gap a concurrent
|
||||||
|
/// writer can land in). A mismatch leaves the row untouched and is
|
||||||
|
/// reported back via the `matched` flag rather than silently
|
||||||
|
/// overwriting a sibling PATCH's content. `None` preserves the old
|
||||||
|
/// blind-overwrite behaviour for PUT/WOPI/chunked-upload finalize,
|
||||||
|
/// where last-write-wins is the intended HTTP semantics.
|
||||||
async fn swap_blob_hash(
|
async fn swap_blob_hash(
|
||||||
&self,
|
&self,
|
||||||
file_id: &str,
|
file_id: &str,
|
||||||
@@ -160,57 +169,83 @@ impl FileBlobWriteRepository {
|
|||||||
new_size: i64,
|
new_size: i64,
|
||||||
modified_at: Option<i64>,
|
modified_at: Option<i64>,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
|
expected_hash: Option<&str>,
|
||||||
) -> Result<(String, i64), DomainError> {
|
) -> Result<(String, i64), DomainError> {
|
||||||
// Atomic CTE: capture old hash then update in one round-trip, no TOCTOU.
|
// Atomic CTE: capture old hash then conditionally update in one
|
||||||
|
// round-trip, no TOCTOU. The CASE arms make the SET a no-op when
|
||||||
|
// `expected_hash` is given and doesn't match `old.blob_hash` —
|
||||||
|
// the row is still returned (with its unchanged values) so the
|
||||||
|
// caller can tell "mismatch" apart from "file not found".
|
||||||
// Deadlock victims (40P01) retry before the compensation below runs —
|
// Deadlock victims (40P01) retry before the compensation below runs —
|
||||||
// a successful retry must keep the new blob reference alive.
|
// a successful retry must keep the new blob reference alive.
|
||||||
let (old_hash, updated_at) = match retry_on_deadlock("files.swap_blob_hash", || {
|
let (old_hash, updated_at, matched) =
|
||||||
sqlx::query_as::<_, (String, i64)>(
|
match retry_on_deadlock("files.swap_blob_hash", || {
|
||||||
r#"
|
sqlx::query_as::<_, (String, i64, bool)>(
|
||||||
|
r#"
|
||||||
WITH old AS (
|
WITH old AS (
|
||||||
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
|
SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE
|
||||||
)
|
)
|
||||||
UPDATE storage.files f
|
UPDATE storage.files f
|
||||||
SET blob_hash = $1, size = $2,
|
SET blob_hash = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
|
||||||
updated_at = COALESCE(to_timestamp($4), NOW()),
|
THEN $1 ELSE f.blob_hash END,
|
||||||
updated_by = $5
|
size = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
|
||||||
|
THEN $2 ELSE f.size END,
|
||||||
|
updated_at = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
|
||||||
|
THEN COALESCE(to_timestamp($4), NOW()) ELSE f.updated_at END,
|
||||||
|
updated_by = CASE WHEN $6::text IS NULL OR old.blob_hash = $6
|
||||||
|
THEN $5 ELSE f.updated_by END
|
||||||
FROM old
|
FROM old
|
||||||
WHERE f.id = old.id
|
WHERE f.id = old.id
|
||||||
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint
|
RETURNING old.blob_hash, EXTRACT(EPOCH FROM f.updated_at)::bigint,
|
||||||
|
($6::text IS NULL OR old.blob_hash = $6)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(new_hash)
|
.bind(new_hash)
|
||||||
.bind(new_size)
|
.bind(new_size)
|
||||||
.bind(file_id)
|
.bind(file_id)
|
||||||
.bind(modified_at.map(|t| t as f64))
|
.bind(modified_at.map(|t| t as f64))
|
||||||
.bind(caller_id)
|
.bind(caller_id)
|
||||||
.fetch_optional(self.pool.as_ref())
|
.bind(expected_hash)
|
||||||
})
|
.fetch_optional(self.pool.as_ref())
|
||||||
.await
|
})
|
||||||
{
|
.await
|
||||||
Ok(Some(row)) => row,
|
{
|
||||||
Ok(None) => {
|
Ok(Some(row)) => row,
|
||||||
// File not found — compensate: remove the new blob ref
|
Ok(None) => {
|
||||||
if let Err(e) = self.dedup.remove_reference(new_hash).await {
|
// File not found — compensate: remove the new blob ref
|
||||||
tracing::error!("Blob orphaned after missing file: {}", e);
|
if let Err(e) = self.dedup.remove_reference(new_hash).await {
|
||||||
|
tracing::error!("Blob orphaned after missing file: {}", e);
|
||||||
|
}
|
||||||
|
return Err(DomainError::not_found("File", file_id));
|
||||||
}
|
}
|
||||||
return Err(DomainError::not_found("File", file_id));
|
Err(e) => {
|
||||||
}
|
// UPDATE failed — compensate: remove the new blob ref
|
||||||
Err(e) => {
|
if let Err(rollback_err) = self.dedup.remove_reference(new_hash).await {
|
||||||
// UPDATE failed — compensate: remove the new blob ref
|
tracing::error!(
|
||||||
if let Err(rollback_err) = self.dedup.remove_reference(new_hash).await {
|
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
|
||||||
tracing::error!(
|
&new_hash[..12],
|
||||||
"Blob orphaned after failed UPDATE — hash: {}, err: {}",
|
rollback_err
|
||||||
&new_hash[..12],
|
);
|
||||||
rollback_err
|
}
|
||||||
);
|
return Err(DomainError::internal_error(
|
||||||
|
"FileBlobWrite",
|
||||||
|
format!("update: {e}"),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return Err(DomainError::internal_error(
|
};
|
||||||
"FileBlobWrite",
|
|
||||||
format!("update: {e}"),
|
if !matched {
|
||||||
));
|
// CAS lost the race — some other writer's content is now the
|
||||||
|
// row's truth. Release the blob we ingested for nothing;
|
||||||
|
// nothing was written.
|
||||||
|
if let Err(e) = self.dedup.remove_reference(new_hash).await {
|
||||||
|
tracing::error!("Blob orphaned after CAS mismatch: {}", e);
|
||||||
}
|
}
|
||||||
};
|
return Err(DomainError::precondition_failed(
|
||||||
|
"File",
|
||||||
|
"content was modified concurrently",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Decrement old blob ref (only if hash changed, best-effort)
|
// Decrement old blob ref (only if hash changed, best-effort)
|
||||||
if old_hash != new_hash
|
if old_hash != new_hash
|
||||||
@@ -790,12 +825,20 @@ impl FileWritePort for FileBlobWriteRepository {
|
|||||||
size: u64,
|
size: u64,
|
||||||
modified_at: Option<i64>,
|
modified_at: Option<i64>,
|
||||||
caller_id: Uuid,
|
caller_id: Uuid,
|
||||||
|
expected_hash: Option<&str>,
|
||||||
) -> Result<(String, i64), DomainError> {
|
) -> Result<(String, i64), DomainError> {
|
||||||
// The content was already ingested into the chunk store by the
|
// The content was already ingested into the chunk store by the
|
||||||
// upload-ingest layer; swap_blob_hash consumes its reference and
|
// upload-ingest layer; swap_blob_hash consumes its reference and
|
||||||
// releases it on failure.
|
// releases it on failure.
|
||||||
let swapped = self
|
let swapped = self
|
||||||
.swap_blob_hash(file_id, blob_hash, size as i64, modified_at, caller_id)
|
.swap_blob_hash(
|
||||||
|
file_id,
|
||||||
|
blob_hash,
|
||||||
|
size as i64,
|
||||||
|
modified_at,
|
||||||
|
caller_id,
|
||||||
|
expected_hash,
|
||||||
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// The file now maps to a different blob — drop the read-side cache
|
// The file now maps to a different blob — drop the read-side cache
|
||||||
// entry so streaming downloads cannot serve the previous content
|
// entry so streaming downloads cannot serve the previous content
|
||||||
|
|||||||
@@ -1972,6 +1972,7 @@ async fn handle_put(
|
|||||||
&content_type,
|
&content_type,
|
||||||
None,
|
None,
|
||||||
user.id,
|
user.id,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -2303,25 +2304,15 @@ async fn handle_patch(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Optimistic-concurrency re-check ───────────────────────────────
|
// ── Atomic store, compare-and-swap on the pre-splice content hash ──
|
||||||
// `file.etag` was snapshotted before the (potentially slow) splice +
|
// `file.content_hash` was snapshotted before the (potentially slow)
|
||||||
// CAS-ingest above. Re-verify nothing else wrote to this file in the
|
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
||||||
// meantime, narrowing the window in which two concurrent PATCHes to
|
// write itself a compare-and-swap: the repository checks and applies
|
||||||
// disjoint ranges — each individually passing its own If-Match check
|
// under the same row lock, so nothing else can write to this file
|
||||||
// against the same stale snapshot — could otherwise silently clobber
|
// between the check and the write. This is what actually closes the
|
||||||
// each other on the blind-overwrite write path below.
|
// race two concurrent PATCHes to disjoint ranges could otherwise hit
|
||||||
if let Ok(current) = file_retrieval_service
|
// — each individually passing its own If-Match check against the
|
||||||
.get_file_by_path(&path, drive_id)
|
// same stale snapshot, then blindly overwriting each other.
|
||||||
.await
|
|
||||||
&& current.etag != file.etag
|
|
||||||
{
|
|
||||||
upload_ingest::discard_ingested(&state.core.dedup_service, &ingested).await;
|
|
||||||
return Err(AppError::precondition_failed(
|
|
||||||
"File was modified concurrently — retry the PATCH",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Atomic store ──────────────────────────────────────────────────
|
|
||||||
let new_size = ingested.size;
|
let new_size = ingested.size;
|
||||||
let content_type = ingested.content_type.clone();
|
let content_type = ingested.content_type.clone();
|
||||||
let result = file_upload_service
|
let result = file_upload_service
|
||||||
@@ -2332,6 +2323,7 @@ async fn handle_patch(
|
|||||||
&content_type,
|
&content_type,
|
||||||
None,
|
None,
|
||||||
user.id,
|
user.id,
|
||||||
|
Some(&file.content_hash),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ async fn put_file(
|
|||||||
&content_type,
|
&content_type,
|
||||||
None,
|
None,
|
||||||
claims_sub_uuid,
|
claims_sub_uuid,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ impl From<DomainError> for AppError {
|
|||||||
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
ErrorKind::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
|
ErrorKind::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
|
||||||
ErrorKind::Conflict => StatusCode::CONFLICT,
|
ErrorKind::Conflict => StatusCode::CONFLICT,
|
||||||
|
ErrorKind::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -466,6 +466,10 @@ async fn handle_assemble(
|
|||||||
// AuthZ audit #2 (2026-07-12): route DomainError through
|
// AuthZ audit #2 (2026-07-12): route DomainError through
|
||||||
// `AppError::from` so authz denials keep the graduated 403/404
|
// `AppError::from` so authz denials keep the graduated 403/404
|
||||||
// shape instead of collapsing into 500.
|
// shape instead of collapsing into 500.
|
||||||
|
//
|
||||||
|
// No client-supplied ETag to enforce here (NC chunked MOVE has no
|
||||||
|
// If-Match semantics) — `expected_hash: None`, same as every other
|
||||||
|
// plain-write callsite; only PATCH's CAS passes `Some(&hash)`.
|
||||||
let dto = match upload_service
|
let dto = match upload_service
|
||||||
.update_file_streaming_with_perms(
|
.update_file_streaming_with_perms(
|
||||||
&internal_path,
|
&internal_path,
|
||||||
@@ -474,6 +478,7 @@ async fn handle_assemble(
|
|||||||
&content_type,
|
&content_type,
|
||||||
oc_mtime,
|
oc_mtime,
|
||||||
user.id,
|
user.id,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -912,6 +912,7 @@ async fn handle_put(
|
|||||||
&content_type,
|
&content_type,
|
||||||
oc_mtime,
|
oc_mtime,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
@@ -1145,25 +1146,15 @@ async fn handle_patch(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Optimistic-concurrency re-check ───────────────────────────────
|
// ── Atomic store, compare-and-swap on the pre-splice content hash ──
|
||||||
// `file.etag` was snapshotted before the (potentially slow) splice +
|
// `file.content_hash` was snapshotted before the (potentially slow)
|
||||||
// CAS-ingest above. Re-verify nothing else wrote to this file in the
|
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
||||||
// meantime, narrowing the window in which two concurrent PATCHes to
|
// write itself a compare-and-swap: the repository checks and applies
|
||||||
// disjoint ranges — each individually passing its own If-Match check
|
// under the same row lock, so nothing else can write to this file
|
||||||
// against the same stale snapshot — could otherwise silently clobber
|
// between the check and the write. This is what actually closes the
|
||||||
// each other on the blind-overwrite write path below.
|
// race two concurrent PATCHes to disjoint ranges could otherwise hit
|
||||||
if let Ok(current) = file_service
|
// — each individually passing its own If-Match check against the
|
||||||
.get_file_by_path(&internal_path, chroot.drive_id)
|
// same stale snapshot, then blindly overwriting each other.
|
||||||
.await
|
|
||||||
&& current.etag != file.etag
|
|
||||||
{
|
|
||||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
|
||||||
return Err(AppError::precondition_failed(
|
|
||||||
"File was modified concurrently — retry the PATCH",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Atomic store ──────────────────────────────────────────────────
|
|
||||||
let new_size = ingested.size;
|
let new_size = ingested.size;
|
||||||
let content_type = ingested.content_type.clone();
|
let content_type = ingested.content_type.clone();
|
||||||
let stored = upload_service
|
let stored = upload_service
|
||||||
@@ -1174,6 +1165,7 @@ async fn handle_patch(
|
|||||||
&content_type,
|
&content_type,
|
||||||
None,
|
None,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
|
Some(&file.content_hash),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user