A related finding on this same code path, with a second trigger that does not involve chunked transfer encoding — and which fastcgi_request_buffering on does not fix.
Symptom: uploading a large file through the web UI in Safari (26.6, macOS) stores a 0-byte file. Every request reports success — MKCOL 201, all five chunk PUTs 201, assembly MOVE .../.file 201 — and nothing is logged. The same file in Chrome uploads correctly. Server: 33.0.7, nginx + php-fpm, S3 (MinIO) primary storage, fastcgi_request_buffering on.
Cause: on the assembly step, File::put() takes the expected write size from the MOVE request's own Content-Length — a header that describes the MOVE (which carries no body), not the assembled stream being written:
apps/dav/lib/Connector/Sabre/File.php (33.0.7, lines 209-223)
$lengthHeader = $this->request->getHeader('content-length');
$expected = $lengthHeader !== '' ? (int)$lengthHeader : null;
...
$count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected);
Safari sends Content-Length: 0 on the MOVE. Chrome omits the header entirely. Since "0" !== '', Safari yields $expected = 0 while Chrome yields null. That matters because ObjectStoreStorage::writeStream() only determines the real size when it was not given one:
lib/private/Files/ObjectStore/ObjectStoreStorage.php
public function writeStream(string $path, $stream, ?int $size = null): int {
if ($size === null) {
$stats = fstat($stream);
if (is_array($stats) && isset($stats['size'])) {
$size = $stats['size'];
}
}
So Safari's 0 skips the fstat() fallback and 0 bytes are written, reported as 201. Chrome's null runs the fallback and gets the correct length.
This appears to be specific to object-storage primary storage: OC\Files\Storage\Local::writeStream() ignores the $size argument altogether and just does file_put_contents(), so a local-storage install should not be affected.
Reproduction with plain curl — no browser involved. 102395904-byte file, five chunks (4×20971520 + 18509824), fastcgi_request_buffering on throughout. The single variable is the Content-Length header on the MOVE:
| chunk PUTs |
Content-Length: 0 on MOVE |
result |
| sequential |
no |
102395904 bytes — correct |
| sequential |
yes |
0 bytes, MOVE 201 |
| parallel |
no |
102395904 bytes — correct |
| parallel |
yes |
0 bytes, MOVE 201 |
ID=repro
curl -X MKCOL "$BASE/remote.php/dav/uploads/$USER/$ID"
n=1; for p in part.*; do curl -X PUT --data-binary @$p "$BASE/remote.php/dav/uploads/$USER/$ID/$n"; n=$((n+1)); done
# succeeds:
curl -X MOVE -H "Destination: $BASE/remote.php/dav/files/$USER/out.pdf" -H "OC-Total-Length: 102395904" \
"$BASE/remote.php/dav/uploads/$USER/$ID/.file"
# stores 0 bytes, returns 201:
curl -X MOVE -H "Destination: $BASE/remote.php/dav/files/$USER/out.pdf" -H "OC-Total-Length: 102395904" \
-H "Content-Length: 0" "$BASE/remote.php/dav/uploads/$USER/$ID/.file"
Workaround without patching PHP. MOVE never carries a body, so the length can be blanked for that method in nginx:
map $request_method $nc_content_length {
default $content_length;
MOVE "";
}
and inside the PHP location, after include fastcgi_params; (with duplicate FastCGI params the last one wins):
fastcgi_param CONTENT_LENGTH $nc_content_length;
fastcgi_param HTTP_CONTENT_LENGTH $nc_content_length;
Both names are required because Request::getHeader() checks HTTP_CONTENT_LENGTH first and only then falls back to CONTENT_LENGTH. Verified: all four rows above become correct, a plain 20971520-byte PUT still stores 20971520, and a genuinely empty file still stores 0 bytes.
Possible fix — treat a zero-length header as absent, so the stream's own size is used:
$expected = ($lengthHeader !== '' && (int)$lengthHeader > 0) ? (int)$lengthHeader : null;
A real 0-byte PUT then takes the fstat() path and still writes 0 bytes, so that case is unchanged. Arguably the assembly path should not consult the request's Content-Length at all, since it never describes the stream being assembled.
Happy to open this as a separate issue if it's considered distinct from the chunked-encoding case reported here.
Originally posted by @mosi-kha in #7995