fix(nc/webdav): honour If-Match / If-None-Match on PUT (RFC 7232)

Closes F5/F6. The NC PUT handler now evaluates conditional
preconditions before body ingestion and returns 412 Precondition
Failed when they fail:

- If-None-Match: * on an existing target → 412 (create-if-absent)
- If-None-Match with matching ETag → 412 (weak compare)
- If-Match: * with no current representation → 412
- If-Match with no listed ETag strong-matching the current → 412

The lookup that drives the precondition reuses the same query the
handler already needed for the 201-vs-204 distinction, so this adds
no extra DB round-trip. Rejected requests skip body ingestion
entirely so a 412 doesn't waste megabytes of bandwidth + disk I/O.

Test F5/F6 flipped from 'pinned current 204' to assert 412, plus
mirror cases F5b/F6b/F6c/F6d covering the legitimate-success paths
so logical-operator regressions can't slip past silently.
This commit is contained in:
Edouard Vanbelle
2026-06-16 23:08:35 +02:00
parent 9190a4806c
commit 3fba933741
2 changed files with 149 additions and 11 deletions
+84 -2
View File
@@ -543,6 +543,62 @@ fn parse_proppatch_favorite(body: &str) -> Option<u8> {
// ──────────────────── PUT ────────────────────
/// Strip the optional `W/` weak prefix and surrounding double-quotes
/// from one ETag value in an `If-Match` / `If-None-Match` list. Returns
/// `(is_weak, inner)`.
fn parse_etag_value(raw: &str) -> (bool, &str) {
let trimmed = raw.trim();
if let Some(rest) = trimmed.strip_prefix("W/") {
(true, rest.trim().trim_matches('"'))
} else {
(false, trimmed.trim_matches('"'))
}
}
/// RFC 7232 §3.2 — `If-None-Match` fails for PUT when:
/// - the header value is `*` and a current representation exists, OR
/// - any listed ETag matches the current representation (weak comparison
/// — weak validators in the request are equivalent to strong for the
/// match itself, only If-Match is required to be strong).
fn if_none_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
let v = header.trim();
if v == "*" {
return current_etag.is_some();
}
let Some(current) = current_etag else {
return false;
};
v.split(',').any(|tag| {
let (_, parsed) = parse_etag_value(tag);
!parsed.is_empty() && parsed == current
})
}
/// RFC 7232 §3.1 — `If-Match` fails for PUT when:
/// - the resource doesn't currently exist (no strong validator to match), OR
/// - the header isn't `*` and no listed ETag strong-matches the current one
/// (weak validators in the request never satisfy a strong-match).
fn if_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
let v = header.trim();
let Some(current) = current_etag else {
return true;
};
if v == "*" {
return false;
}
!v.split(',').any(|tag| {
let (is_weak, parsed) = parse_etag_value(tag);
!is_weak && !parsed.is_empty() && parsed == current
})
}
fn precondition_failed_response() -> Response<Body> {
Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())
.unwrap()
}
async fn handle_put(
state: Arc<AppState>,
req: Request<Body>,
@@ -566,6 +622,31 @@ async fn handle_put(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
// ── Conditional preconditions (RFC 7232 §3.1 / §3.2) ─────────────
// Evaluated BEFORE body ingestion so a rejected PUT doesn't waste
// bandwidth or disk I/O on a body the server is going to throw away.
// The lookup is reused for the create-vs-update distinction below,
// so this is also free of an extra DB hit.
let existing = file_service.get_file_by_path(&internal_path).await.ok();
let current_etag = existing.as_ref().map(|f| f.etag.as_str());
if let Some(value) = req
.headers()
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok())
&& if_none_match_precondition_fails(value, current_etag)
{
return Ok(precondition_failed_response());
}
if let Some(value) = req
.headers()
.get(header::IF_MATCH)
.and_then(|v| v.to_str().ok())
&& if_match_precondition_fails(value, current_etag)
{
return Ok(precondition_failed_response());
}
// ── Direct PUT cap ───────────────────────────────────────────────
// We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size`
// (default 10 GB). Larger files must come through the chunked upload
@@ -594,8 +675,9 @@ async fn handle_put(
.await?;
let content_type = ingested.content_type.clone();
// Distinguish create (201) vs update (204) for the response status.
let existed = file_service.get_file_by_path(&internal_path).await.is_ok();
// Distinguish create (201) vs update (204) for the response status,
// using the lookup already done above for the precondition check.
let existed = existing.is_some();
// Single streaming path — handles both update and create internally,
// swapping the file row onto the already-ingested blob.