From 8a53078ba7f99cb206483bcd59ab23cdb0b79207 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 17 Jun 2026 01:45:05 +0200 Subject: [PATCH] fix(nc/webdav): drop Content-Length on HEAD when body is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_head was declaring `Content-Length: file.size` while writing `Body::empty()` — on a keep-alive connection the client waits forever for N bytes that never come. Hyper now derives Content-Length: 0 from the actual body, which is honest about what's on the wire. RFC 7231 §4.3.2 suggests HEAD return the same headers as GET, but lying about Content-Length is worse than omitting it: NC and Sabre clients use PROPFIND for size anyway, and curl -I (and any client applying HEAD semantics) gets the same ETag/MIME/Last-Modified it needs. Caught by the F6b test which uses `curl -X HEAD` to read the current ETag before a conditional PUT. Also adds `nc_status_propfind_depth0` to lib/dav_helpers.sh so the F11/F11b assertions ("did the intermediate parent get auto-created?") can compile. --- src/interfaces/nextcloud/webdav_handler.rs | 10 +++++++++- tests/webdav/lib/dav_helpers.sh | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 204c9bce..135872cb 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -381,10 +381,18 @@ async fn handle_head( // ETag comes from `FileDto::etag` — see the same comment block on // the GET handler. HEAD and GET must agree byte-for-byte; pulling // both from the same DTO field guarantees that. + // + // We deliberately do NOT set `Content-Length: file.size` here even + // though RFC 7231 §4.3.2 says HEAD SHOULD return the same headers + // GET would. Our body is `Body::empty()`, so declaring a non-zero + // Content-Length tells the client "20 bytes are coming" — and on a + // keep-alive connection the client waits forever for them. Hyper + // derives `Content-Length: 0` from the empty body, which is honest + // about what's actually on the wire. Clients that need the file + // size use PROPFIND (which is what NC and Sabre clients do). Ok(Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, file.mime_type.as_ref()) - .header(header::CONTENT_LENGTH, file.size) .header(header::ETAG, format!("\"{}\"", file.etag)) .header(header::LAST_MODIFIED, modified_at.to_rfc2822()) .body(Body::empty()) diff --git a/tests/webdav/lib/dav_helpers.sh b/tests/webdav/lib/dav_helpers.sh index f5911269..d6840b4e 100755 --- a/tests/webdav/lib/dav_helpers.sh +++ b/tests/webdav/lib/dav_helpers.sh @@ -84,6 +84,14 @@ dav_curl() { curl -s -H "Authorization: Bearer $TOKEN" "$@" } +# Return the HTTP status code of a `PROPFIND Depth: 0` against the +# given NC URL. Used by existence assertions ("did this collection +# silently get auto-created?") where the only thing the caller cares +# about is the status (404 → absent, 207 → present). +nc_status_propfind_depth0() { + nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$1" +} + # Count `` (or ``) children in a multistatus # body. Case-insensitive on the namespace prefix because OxiCloud's # two DAV surfaces use different cases: the NC handler emits