{
    "type": "article",
    "title": "Writing down an upload protocol and testing servers against it",
    "canonical": "https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol",
    "path": "/writing/testing-servers-against-an-upload-protocol",
    "url": "https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol",
    "markdown": "https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol.md",
    "json": "https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol.json",
    "summary": "How TurboPush’s chunked upload protocol is written down in PROTOCOL.md, how a small Node suite checks any server against it over HTTP, and the two bugs it found in my own PHP endpoint.",
    "published": "2026-10-20T00:00:00+01:00",
    "modified": "2026-10-25T18:32:07+00:00",
    "image": {
        "url": "https://www.dealmobile.co.uk/sites/default/files/content/images/posts/testing-servers-against-an-upload-protocol/hero.png",
        "alt": "Terminal output of TurboPush’s conformance suite, a list of passing checks with the two that found bugs in the PHP endpoint highlighted, ending in 74 checks, 70 passed, 0 failed."
    },
    "fields": {
        "image_light": null
    },
    "tags": [
        {
            "name": "PHP",
            "url": "https://www.dealmobile.co.uk/writing/tag/php",
            "json": "https://www.dealmobile.co.uk/writing/tag/php.json"
        },
        {
            "name": "JavaScript",
            "url": "https://www.dealmobile.co.uk/writing/tag/javascript",
            "json": "https://www.dealmobile.co.uk/writing/tag/javascript.json"
        },
        {
            "name": "Uploads",
            "url": "https://www.dealmobile.co.uk/writing/tag/uploads",
            "json": "https://www.dealmobile.co.uk/writing/tag/uploads.json"
        },
        {
            "name": "Testing",
            "url": "https://www.dealmobile.co.uk/writing/tag/testing",
            "json": "https://www.dealmobile.co.uk/writing/tag/testing.json"
        },
        {
            "name": "TurboPush",
            "url": "https://www.dealmobile.co.uk/writing/tag/turbopush",
            "json": "https://www.dealmobile.co.uk/writing/tag/turbopush.json"
        }
    ],
    "projects": [
        {
            "name": "TurboPush",
            "url": "https://www.dealmobile.co.uk/projects/turbopush",
            "json": "https://www.dealmobile.co.uk/projects/turbopush.json"
        }
    ],
    "body": {
        "html": "<p><a href=\"/projects/turbopush\">TurboPush</a> has two halves: a TypeScript client that sends files in chunks from the browser, and a PHP endpoint that checks them and puts them back together. The README says either half can be paired with your own code. That’s only true if the conversation between them is written down somewhere other than inside a PHP class. So version 2.1 added <code>PROTOCOL.md</code>, a specification of the protocol, and a conformance suite that checks any server against it over HTTP. How the endpoint itself handles chunks is in <a href=\"/writing/making-large-uploads-resumable\">Making large uploads resumable</a>.</p>\n<h2 id=\"the-protocol-on-paper\">The protocol on paper</h2>\n<p>The protocol is small. Every operation is a <code>POST</code> to one URL. A multipart body is a chunk, and a JSON body with an <code>action</code> of <code>finalize</code>, <code>status</code> or <code>abort</code> is one of the other three operations. Every response is the same JSON envelope, <code>{\"success\": ..., \"message\": ..., \"data\": ...}</code>, and each status code has one meaning: 404 for an upload the server doesn’t hold, 409 for a conflict with the upload’s state, 413 for a limit, 415 for a refused file type.</p>\n<p><code>PROTOCOL.md</code> is written so that someone could build a server in another language without reading the PHP. It uses RFC 2119’s MUST and SHOULD, and calls the PHP class “the reference implementation” wherever it describes what that class happens to do. The detail is in the places where a reasonable server could go either way. For example:</p>\n<ul>\n<li>An upload ID must match <code>^[A-Za-z0-9_-]{1,200}$</code> in full, and anything else is a 400 “without trimming, decoding or rewriting it”, because servers use the ID as a directory name.</li>\n<li>A number in a multipart field is ASCII digits only. <code>+2000</code>, <code> 2000</code>, <code>0x10</code> and <code>1e3</code> are all 400s.</li>\n<li>An empty <code>data</code> may be sent as <code>[]</code>, because that’s how PHP encodes an empty array, so clients must accept <code>{}</code> and <code>[]</code>.</li>\n<li>A JSON number such as <code>10.0</code> may be rejected even though it’s whole, because parsers in some languages can’t tell it from <code>10</code>. The suite doesn’t test that case, and clients must send plain integers.</li>\n</ul>\n<p>That last kind of note matters. A rule only the PHP version could meet would make the document a description of one server, not a protocol.</p>\n<h2 id=\"markers-that-link-the-two\">Markers that link the two</h2>\n<p>Every requirement the suite verifies carries the ID of the check that verifies it:</p>\n<pre><code class=\"language-markdown\">- Sending a chunk again MUST succeed and MUST NOT count its bytes twice [check: `upload.duplicate-chunk`].\n</code></pre>\n<p>A requirement without a marker isn’t checked over HTTP, usually because it needs the server’s internals or a long wait, such as deleting abandoned uploads after 24 hours. So the gaps are visible in the specification itself, and a new check isn’t finished until its marker is in place.</p>\n<h2 id=\"the-suite\">The suite</h2>\n<p>The suite is plain Node.js 18 or later, with no dependencies. It talks HTTP through <code>node:http</code> rather than <code>fetch</code>, and builds its multipart bodies by hand, so every header and every byte is under its control: an <code>Origin</code> header, a field called <code>file[]</code>, a NUL byte in a value.</p>\n<p>A check is an object with an ID, a severity, a description and a <code>run()</code> function. This is the one behind that first marker:</p>\n<pre><code class=\"language-js\">{\n    id: 'upload.duplicate-chunk',\n    severity: 'MUST',\n    description: 'Resending a chunk is harmless and is not counted twice',\n    async run(ctx) {\n        const fileId = ctx.id();\n        const fileName = ctx.name('dup', 'bin');\n        const data = randomBytes(2000);\n        await ctx.sendChunks({ fileId, fileName, data, order: [0, 0, 1, 1, 0] });\n        const status = expectOk(await ctx.status(fileId), 'status');\n        expect(status.receivedBytes === 2000,\n            `status: receivedBytes is ${JSON.stringify(status.receivedBytes)} after resends, expected 2000`);\n        const response = await ctx.finalize({ fileId, fileName, fileSize: 2000, totalChunks: 2 });\n        expectStored(ctx, expectOk(response, 'finalize'), data);\n    },\n},\n</code></pre>\n<p>There are 74 checks, 66 of them MUST and 8 SHOULD, in six files: the upload lifecycle, request validation, security, concurrency, CORS, and sweeps. A failed MUST fails the run and a failed SHOULD is a warning. A check that needs something you didn’t supply, such as the server’s limits or an origin on its allowlist, is skipped rather than failed.</p>\n<p>The concurrency checks are the ones I’d least like to do by hand. Sixteen chunks of one file arrive at once, in reverse order. Six copies of the same chunk, each with different bytes, arrive together, and all six must succeed and leave exactly one complete version. Eight finalize requests for one upload race each other, and all eight must return the same file name, with one file stored. Four uploads of the same name finalize together and must get four distinct files.</p>\n<h2 id=\"looking-inside-when-it-can\">Looking inside, when it can</h2>\n<p>The suite treats the server as a black box, but if it can read the server’s upload directory (<code>--upload-dir</code>), it checks more. Stored files are compared byte for byte with what was sent, and afterwards the directory mustn’t contain any file the server didn’t report.</p>\n<p>The path traversal check uses that too. It uploads files named <code>../../&lt;marker&gt;.txt</code>, <code>C:\\&lt;marker&gt;.txt</code>, <code>....//....//&lt;marker&gt;.txt</code>, <code>\\\\server\\share\\&lt;marker&gt;.txt</code>, <code>..%2F..%2F&lt;marker&gt;.txt</code> and a few more, each with a random marker. A server may refuse each one with a 4xx, or store it under a single plain name inside the upload directory. Then the suite looks for the marker in the system temp directory, the filesystem root, the current directory and the two directories above the upload directory, in case a file got out.</p>\n<p>Some checks run last, over every response the others received. Every response must be the JSON envelope, with <code>success</code> matching the status code. No error may contain a server path: the sweep looks for absolute paths such as <code>/var/...</code> and <code>C:\\...</code> and for the temp and upload directories, ignoring any path the request itself sent, such as a file name the server echoed back.</p>\n<h2 id=\"what-it-found\">What it found</h2>\n<p>The suite’s first target was my own endpoint, and it found two bugs there.</p>\n<p>The first was PHP’s <code>switch</code>, which compares loosely. A request with <code>{\"action\": true}</code> matched <code>case 'finalize'</code>, as did <code>0</code> on PHP 7, and with the other fields filled in it really finalized the upload. The fix is to treat anything that isn’t a string as no action at all:</p>\n<pre><code class=\"language-php\">// switch compares loosely, so a non-string such as true (or 0 on\n// PHP 7) would otherwise match 'finalize'\n$action = is_string($data['action']) ? $data['action'] : '';\nswitch ($action) {\n    case 'finalize':\n        return $this-&gt;finalizeUpload($data);\n</code></pre>\n<p>The check behind it, <code>action.non-string</code>, sends <code>true</code>, <code>0</code>, <code>1</code>, <code>[\"finalize\"]</code> and <code>{\"name\": \"finalize\"}</code>, and expects a 400 for each.</p>\n<p>The second was the content type. JSON actions must arrive as <code>application/json</code>, because browsers send plain text, form and multipart requests to other sites without asking first, and a JSON content type forces them to ask. The endpoint checked that the header started with <code>application/json</code>, so <code>application/jsonx</code> got through. It now compares the media type exactly, ignoring case and parameters such as <code>charset</code>. A neighbouring check, <code>csrf.simple-requests</code>, sends an <code>abort</code> as plain text, as a form and as multipart, and then asks for the upload’s status to make sure it’s still there.</p>\n<h2 id=\"running-it-on-every-php-version\">Running it on every PHP version</h2>\n<p><code>conformance/php.mjs</code> runs the suite against the reference endpoint. It starts PHP’s built-in server on a free port with fresh temporary storage and four workers, because the built-in server otherwise handles one request at a time and the concurrency checks would prove nothing. PHP runs with every error reported and displayed, so any warning or deprecation breaks that response’s JSON and fails the envelope check. The suite runs twice, the second time against an endpoint with a CORS allowlist.</p>\n<p>GitHub Actions runs it on every PHP version from 7.4 to 8.5. On PHP 8.1 on my machine, the first pass sends 579 requests in 1.4 seconds: 70 checks pass and 4 are skipped, because they need an allowlisted origin. Those four pass in the second run.</p>\n<h2 id=\"what-it-cant-tell-you\">What it can’t tell you</h2>\n<p>Anything that can only be seen inside the server is out of reach. The suite doesn’t wait 24 hours to see abandoned uploads deleted, and it can’t see that the PHP endpoint’s locks are unreliable on NFS, which the specification says plainly instead. Passing means a server behaves correctly over HTTP in the ways the checks probe. It doesn’t prove the server is right.</p>\n<h2 id=\"using-it\">Using it</h2>\n<pre><code class=\"language-bash\">npm run conformance:php        # against the PHP endpoint, as CI does\nnode conformance/run.mjs --list\n\nnode conformance/run.mjs --url http://localhost:8000/upload.php \\\n  --max-file-size 8388608 --max-chunk-size 262144 --max-chunks 64 \\\n  --upload-dir /srv/uploads --only fileid.,filename.\n</code></pre>\n<p>Point it at a test deployment, not a live one: it leaves small files whose names start with <code>tpc-</code> in the upload directory. The exit code is 1 if any MUST check fails. The code, <code>PROTOCOL.md</code> and the suite are on <a href=\"https://github.com/tomgould/TurboPush\">GitHub</a>.</p>"
    },
    "markdown_text": "---\ntitle: 'Writing down an upload protocol and testing servers against it'\ncanonical: 'https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol'\nurl: 'https://www.dealmobile.co.uk/writing/testing-servers-against-an-upload-protocol'\ntype: Post\npublished: '2026-10-20'\nsummary: 'How TurboPush’s chunked upload protocol is written down in PROTOCOL.md, how a small Node suite checks any server against it over HTTP, and the two bugs it found in my own PHP endpoint.'\n---\n\n# Writing down an upload protocol and testing servers against it\n\nPublished 2026-10-20\n\n![Terminal output of TurboPush’s conformance suite, a list of passing checks with the two that found bugs in the PHP endpoint highlighted, ending in 74 checks, 70 passed, 0 failed.](https://www.dealmobile.co.uk/sites/default/files/styles/almanac_shot_md/public/content/images/posts/testing-servers-against-an-upload-protocol/hero.png.webp?itok=afSzxyZv)\n\n[TurboPush](https://www.dealmobile.co.uk/projects/turbopush.md) has two halves: a TypeScript client that sends files in chunks from the browser, and a PHP endpoint that checks them and puts them back together. The README says either half can be paired with your own code. That’s only true if the conversation between them is written down somewhere other than inside a PHP class. So version 2.1 added `PROTOCOL.md`, a specification of the protocol, and a conformance suite that checks any server against it over HTTP. How the endpoint itself handles chunks is in [Making large uploads resumable](https://www.dealmobile.co.uk/writing/making-large-uploads-resumable.md).\n\n## The protocol on paper\n\nThe protocol is small. Every operation is a `POST` to one URL. A multipart body is a chunk, and a JSON body with an `action` of `finalize`, `status` or `abort` is one of the other three operations. Every response is the same JSON envelope, `{\"success\": ..., \"message\": ..., \"data\": ...}`, and each status code has one meaning: 404 for an upload the server doesn’t hold, 409 for a conflict with the upload’s state, 413 for a limit, 415 for a refused file type.\n\n`PROTOCOL.md` is written so that someone could build a server in another language without reading the PHP. It uses RFC 2119’s MUST and SHOULD, and calls the PHP class “the reference implementation” wherever it describes what that class happens to do. The detail is in the places where a reasonable server could go either way. For example:\n\n- An upload ID must match `^[A-Za-z0-9_-]{1,200}$` in full, and anything else is a 400 “without trimming, decoding or rewriting it”, because servers use the ID as a directory name.\n- A number in a multipart field is ASCII digits only. `+2000`, ` 2000`, `0x10` and `1e3` are all 400s.\n- An empty `data` may be sent as `[]`, because that’s how PHP encodes an empty array, so clients must accept `{}` and `[]`.\n- A JSON number such as `10.0` may be rejected even though it’s whole, because parsers in some languages can’t tell it from `10`. The suite doesn’t test that case, and clients must send plain integers.\n\nThat last kind of note matters. A rule only the PHP version could meet would make the document a description of one server, not a protocol.\n\n## Markers that link the two\n\nEvery requirement the suite verifies carries the ID of the check that verifies it:\n\n```markdown\n- Sending a chunk again MUST succeed and MUST NOT count its bytes twice [check: `upload.duplicate-chunk`].\n\n```\n\nA requirement without a marker isn’t checked over HTTP, usually because it needs the server’s internals or a long wait, such as deleting abandoned uploads after 24 hours. So the gaps are visible in the specification itself, and a new check isn’t finished until its marker is in place.\n\n## The suite\n\nThe suite is plain Node.js 18 or later, with no dependencies. It talks HTTP through `node:http` rather than `fetch`, and builds its multipart bodies by hand, so every header and every byte is under its control: an `Origin` header, a field called `file[]`, a NUL byte in a value.\n\nA check is an object with an ID, a severity, a description and a `run()` function. This is the one behind that first marker:\n\n```js\n{\n    id: 'upload.duplicate-chunk',\n    severity: 'MUST',\n    description: 'Resending a chunk is harmless and is not counted twice',\n    async run(ctx) {\n        const fileId = ctx.id();\n        const fileName = ctx.name('dup', 'bin');\n        const data = randomBytes(2000);\n        await ctx.sendChunks({ fileId, fileName, data, order: [0, 0, 1, 1, 0] });\n        const status = expectOk(await ctx.status(fileId), 'status');\n        expect(status.receivedBytes === 2000,\n            `status: receivedBytes is ${JSON.stringify(status.receivedBytes)} after resends, expected 2000`);\n        const response = await ctx.finalize({ fileId, fileName, fileSize: 2000, totalChunks: 2 });\n        expectStored(ctx, expectOk(response, 'finalize'), data);\n    },\n},\n\n```\n\nThere are 74 checks, 66 of them MUST and 8 SHOULD, in six files: the upload lifecycle, request validation, security, concurrency, CORS, and sweeps. A failed MUST fails the run and a failed SHOULD is a warning. A check that needs something you didn’t supply, such as the server’s limits or an origin on its allowlist, is skipped rather than failed.\n\nThe concurrency checks are the ones I’d least like to do by hand. Sixteen chunks of one file arrive at once, in reverse order. Six copies of the same chunk, each with different bytes, arrive together, and all six must succeed and leave exactly one complete version. Eight finalize requests for one upload race each other, and all eight must return the same file name, with one file stored. Four uploads of the same name finalize together and must get four distinct files.\n\n## Looking inside, when it can\n\nThe suite treats the server as a black box, but if it can read the server’s upload directory (`--upload-dir`), it checks more. Stored files are compared byte for byte with what was sent, and afterwards the directory mustn’t contain any file the server didn’t report.\n\nThe path traversal check uses that too. It uploads files named `../../<marker>.txt`, `C:\\<marker>.txt`, `....//....//<marker>.txt`, `\\\\server\\share\\<marker>.txt`, `..%2F..%2F<marker>.txt` and a few more, each with a random marker. A server may refuse each one with a 4xx, or store it under a single plain name inside the upload directory. Then the suite looks for the marker in the system temp directory, the filesystem root, the current directory and the two directories above the upload directory, in case a file got out.\n\nSome checks run last, over every response the others received. Every response must be the JSON envelope, with `success` matching the status code. No error may contain a server path: the sweep looks for absolute paths such as `/var/...` and `C:\\...` and for the temp and upload directories, ignoring any path the request itself sent, such as a file name the server echoed back.\n\n## What it found\n\nThe suite’s first target was my own endpoint, and it found two bugs there.\n\nThe first was PHP’s `switch`, which compares loosely. A request with `{\"action\": true}` matched `case 'finalize'`, as did `0` on PHP 7, and with the other fields filled in it really finalized the upload. The fix is to treat anything that isn’t a string as no action at all:\n\n```php\n// switch compares loosely, so a non-string such as true (or 0 on\n// PHP 7) would otherwise match 'finalize'\n$action = is_string($data['action']) ? $data['action'] : '';\nswitch ($action) {\n    case 'finalize':\n        return $this->finalizeUpload($data);\n\n```\n\nThe check behind it, `action.non-string`, sends `true`, `0`, `1`, `[\"finalize\"]` and `{\"name\": \"finalize\"}`, and expects a 400 for each.\n\nThe second was the content type. JSON actions must arrive as `application/json`, because browsers send plain text, form and multipart requests to other sites without asking first, and a JSON content type forces them to ask. The endpoint checked that the header started with `application/json`, so `application/jsonx` got through. It now compares the media type exactly, ignoring case and parameters such as `charset`. A neighbouring check, `csrf.simple-requests`, sends an `abort` as plain text, as a form and as multipart, and then asks for the upload’s status to make sure it’s still there.\n\n## Running it on every PHP version\n\n`conformance/php.mjs` runs the suite against the reference endpoint. It starts PHP’s built-in server on a free port with fresh temporary storage and four workers, because the built-in server otherwise handles one request at a time and the concurrency checks would prove nothing. PHP runs with every error reported and displayed, so any warning or deprecation breaks that response’s JSON and fails the envelope check. The suite runs twice, the second time against an endpoint with a CORS allowlist.\n\nGitHub Actions runs it on every PHP version from 7.4 to 8.5. On PHP 8.1 on my machine, the first pass sends 579 requests in 1.4 seconds: 70 checks pass and 4 are skipped, because they need an allowlisted origin. Those four pass in the second run.\n\n## What it can’t tell you\n\nAnything that can only be seen inside the server is out of reach. The suite doesn’t wait 24 hours to see abandoned uploads deleted, and it can’t see that the PHP endpoint’s locks are unreliable on NFS, which the specification says plainly instead. Passing means a server behaves correctly over HTTP in the ways the checks probe. It doesn’t prove the server is right.\n\n## Using it\n\n```bash\nnpm run conformance:php        # against the PHP endpoint, as CI does\nnode conformance/run.mjs --list\n\nnode conformance/run.mjs --url http://localhost:8000/upload.php \\\n  --max-file-size 8388608 --max-chunk-size 262144 --max-chunks 64 \\\n  --upload-dir /srv/uploads --only fileid.,filename.\n\n```\n\nPoint it at a test deployment, not a live one: it leaves small files whose names start with `tpc-` in the upload directory. The exit code is 1 if any MUST check fails. The code, `PROTOCOL.md` and the suite are on [GitHub](https://github.com/tomgould/TurboPush).\n\n- **Projects:** [TurboPush](https://www.dealmobile.co.uk/projects/turbopush.md)\n- **Tags:** [PHP](https://www.dealmobile.co.uk/writing/tag/php.md), [JavaScript](https://www.dealmobile.co.uk/writing/tag/javascript.md), [Uploads](https://www.dealmobile.co.uk/writing/tag/uploads.md), [Testing](https://www.dealmobile.co.uk/writing/tag/testing.md), [TurboPush](https://www.dealmobile.co.uk/writing/tag/turbopush.md)\n\n---\n\nIndex of this site: [llms.txt](https://www.dealmobile.co.uk/llms.txt)\n"
}