Skip to content

fix(storage): send explicit content-length on S3 requests - #235

Open
breken-ai wants to merge 1 commit into
utopia-php:mainfrom
breken-ai:fix-s3-content-length
Open

fix(storage): send explicit content-length on S3 requests#235
breken-ai wants to merge 1 commit into
utopia-php:mainfrom
breken-ai:fix-s3-content-length

Conversation

@breken-ai

Copy link
Copy Markdown

Fixes appwrite/appwrite#13548

Problem

A user reported on appwrite/appwrite today that file uploads to S3-compatible storage (GCP Cloud Storage's XML API in their case) fail with 411 Length Required as soon as the file is big enough to be chunked. Strict S3-compatible endpoints reject any request that goes out without an explicit Content-Length, and multipart uploadPart PUTs were exactly that: only content-type, content-md5, host/date and the SigV4 headers were set, so the transport fell back to chunked transfer encoding.

Root cause

S3::call() builds the PSR-7 request without a Content-Length header. When the body stream reports a size the transport may add one, but upload pipelines frequently hand over streams whose getSize() is null (decorated/pump streams), so the request leaves with Transfer-Encoding: chunked and the endpoint 411s.

Fix

S3::call() now sets content-length on every request, measured from the body stream. When getSize() returns null but the stream is seekable (which hashBody() already requires), the size is measured by seeking to the end and back. The header is included in the SigV4 signed headers, which AWS and compatible endpoints accept.

+25 lines in packages/storage/src/Storage/Device/S3.php, plus 3 regression tests.

Evidence

Tests added in packages/storage/tests/Storage/Device/S3Test.php with a capturing PSR-18 client that records the requests the real S3::call() puts on the wire:

  • testWriteSendsContentLength - single PUT carries content-length: 11 for an 11-byte body, and the header is covered by the signature
  • testMultipartUploadSendsContentLengthForEveryRequest - every request in the multipart flow (createMultipartUpload, both uploadParts, the exists probe, completeMultipartUpload) carries a correct content-length
  • testContentLengthIsMeasuredForSeekableStreamWithUnknownSize - a stream reporting getSize() === null still yields content-length: 11
$ php vendor/bin/phpunit -c packages/storage/phpunit.xml --testsuite unit
OK (84 tests, 286 assertions)   # 81 existing + 3 new, PHP 8.5.8

bin/monorepo check storage --fix (Pint + PHPStan + Rector) passes clean. The docker-backed E2E suite was not runnable locally; the unit suite is fully green.

Built by breken, your AI support engineer - breken.ai - this one's on us.

Strict S3-compatible endpoints (e.g. Google Cloud Storage's XML API)
reject requests that go out with no Content-Length (chunked transfer)
with a 411, which broke every multipart upload to those backends.
Stream sizes are measured when getSize() returns null but the stream
is seekable.

Fixes appwrite/appwrite#13548
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

This PR is not safe to merge because unknown-size uploads can remain chunked in the default cURL transport, and the implementation-coupled tests must also be replaced to satisfy the repository requirement.

Fix All in Claude CodeFindings

  1. P1 Unknown uploads remain chunked
  2. P2 Tests mirror request internals

Summary

  • The default cURL transport is not given the measured size and can still frame unknown-size uploads as chunked.
  • The new tests bypass that transport and tightly mirror internal request construction, contrary to the repository's testing rule.

$headers['host'] = $this->host;
$headers['date'] = gmdate('D, d M Y H:i:s T');
$headers['content-md5'] = $md5;
$headers['content-length'] = (string) $this->bodyLength($body);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unknown uploads remain chunked

For seekable streams whose getSize() is unknown—the exact case this change intends to fix—setting this header does not give the default cURL transport the measured size. The transport calls getSize() again, leaves CURLOPT_INFILESIZE unset, and configures an HTTP/1.1 upload with an unknown length. It can therefore still use chunked transfer encoding despite the signed Content-Length, causing strict S3-compatible endpoints to continue rejecting these uploads. Pass the measured length to the transport or otherwise ensure it uses this value for request framing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/storage/src/Storage/Device/S3.php
Line: 750

Comment:
**Unknown uploads remain chunked**

For seekable streams whose `getSize()` is unknown—the exact case this change intends to fix—setting this header does not give the default cURL transport the measured size. The transport calls `getSize()` again, leaves `CURLOPT_INFILESIZE` unset, and configures an HTTP/1.1 upload with an unknown length. It can therefore still use chunked transfer encoding despite the signed `Content-Length`, causing strict S3-compatible endpoints to continue rejecting these uploads. Pass the measured length to the transport or otherwise ensure it uses this value for request framing.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +751 to +760
// createMultipartUpload (POST, empty body), two uploadPart PUTs,
// exists() probe (HEAD), completeMultipartUpload (POST, XML body)
$this->assertCount(5, $client->requests);
$this->assertSame('0', $client->requests[0]->getHeaderLine('content-length'));
$this->assertSame('10', $client->requests[1]->getHeaderLine('content-length'));
$this->assertSame('6', $client->requests[2]->getHeaderLine('content-length'));
$this->assertSame('0', $client->requests[3]->getHeaderLine('content-length'));
$completeLength = (int) $client->requests[4]->getHeaderLine('content-length');
$this->assertGreaterThan(0, $completeLength);
$this->assertSame(\strlen((string) $client->requests[4]->getBody()), $completeLength);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests mirror request internals

This test mirrors the implementation's exact five-request sequence, ordering, and body lengths instead of testing observable endpoint behavior. That violates the repository directive against implementation-coupled tests. The same coupling appears in the authorization-string assertion and the unknown-size-stream header assertion. More importantly, the capturing fake bypasses the real cURL framing behavior, allowing the transport regression to pass. This repository requirement must be satisfied before merging; replace these checks with a protocol-level test using the actual adapter and a strict endpoint.

Context Used: Call out and harshly judge implementation-coupled ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/storage/tests/Storage/Device/S3Test.php
Line: 751-760

Comment:
**Tests mirror request internals**

This test mirrors the implementation's exact five-request sequence, ordering, and body lengths instead of testing observable endpoint behavior. That violates the repository directive against implementation-coupled tests. The same coupling appears in the authorization-string assertion and the unknown-size-stream header assertion. More importantly, the capturing fake bypasses the real cURL framing behavior, allowing the transport regression to pass. This repository requirement must be satisfied before merging; replace these checks with a protocol-level test using the actual adapter and a strict endpoint.

**Context Used:** Call out and harshly judge implementation-coupled ... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2.0 selfhost: Files fail to upload to s3 compatible storages due to missing Content-length header

1 participant