Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/Drivers/LaravelHttpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Pest\Browser\Execution;
use Pest\Browser\GlobalState;
use Pest\Browser\Playwright\Playwright;
use Pest\Browser\Support\MultipartFormDataParser;
use Psr\Log\NullLogger;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
Expand Down Expand Up @@ -241,8 +242,12 @@ private function handleRequest(AmpRequest $request): Response
$method = mb_strtoupper($request->getMethod());
$rawBody = (string) $request->getBody();
$parameters = [];
if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) {
$files = [];
$normalizedContentType = mb_strtolower($contentType);
if ($method !== 'GET' && str_starts_with($normalizedContentType, 'application/x-www-form-urlencoded')) {
parse_str($rawBody, $parameters);
} elseif ($method !== 'GET' && str_starts_with($normalizedContentType, 'multipart/form-data')) {
[$parameters, $files] = MultipartFormDataParser::parse($rawBody, $contentType);
}
$cookies = array_map(fn (RequestCookie $cookie): string => urldecode($cookie->getValue()), $request->getCookies());
$cookies = array_merge($cookies, test()->prepareCookiesForRequest()); // @phpstan-ignore-line
Expand All @@ -254,7 +259,7 @@ private function handleRequest(AmpRequest $request): Response
$method,
$parameters,
$cookies,
[], // @TODO files...
$files,
$serverVariables,
$rawBody
);
Expand Down
223 changes: 223 additions & 0 deletions src/Support/MultipartFormDataParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<?php

declare(strict_types=1);

namespace Pest\Browser\Support;

/**
* Parses a raw `multipart/form-data` request body into the "parameters" and
* "files" arrays that `Symfony\Component\HttpFoundation\Request::create()`
* expects, so requests sent by a real browser through a file input reach the
* application the same way they would behind a normal PHP SAPI.
*
* @internal
*/
final class MultipartFormDataParser
{
/**
* Parses the given body, returning `[$parameters, $files]`.
*
* @return array{0: array<array-key, mixed>, 1: array<array-key, mixed>}
*/
public static function parse(string $body, string $contentType): array
{
$parameters = [];
$files = [];

$boundary = self::boundary($contentType);

if ($boundary === null) {
return [$parameters, $files];
}

foreach (self::parts($body, $boundary) as [$headers, $content]) {
$disposition = $headers['content-disposition'] ?? '';

if (preg_match('/name="(?<name>[^"]*)"/', $disposition, $nameMatch) !== 1) {
continue;
}

$path = self::path($nameMatch['name']);

if (preg_match('/filename="(?<filename>[^"]*)"/', $disposition, $filenameMatch) === 1) {
self::setFile($files, $path, $filenameMatch['filename'], $headers['content-type'] ?? null, $content);

continue;
}

self::set($parameters, $path, $content);
}

return [$parameters, $files];
}

/**
* Extracts the boundary token from a `multipart/form-data` content type header.
*/
private static function boundary(string $contentType): ?string
{
if (preg_match('/boundary=(?:"(?<quoted>[^"]+)"|(?<bare>[^;]+))/', $contentType, $matches) !== 1) {
return null;
}

$boundary = $matches['quoted'] !== '' ? $matches['quoted'] : $matches['bare'];

return rtrim($boundary);
}

/**
* Splits the raw body into `[headers, content]` pairs, one per part.
*
* @return list<array{0: array<string, string>, 1: string}>
*/
private static function parts(string $body, string $boundary): array
{
$segments = explode('--'.$boundary, $body);

// The first segment is the preamble before the first boundary, and the
// last is whatever follows the closing `--boundary--`; neither is a part.
array_shift($segments);
array_pop($segments);

$parts = [];

foreach ($segments as $segment) {
$segment = ltrim($segment, "\r\n");
$headerEnd = strpos($segment, "\r\n\r\n");

if ($headerEnd === false) {
continue;
}

$content = substr($segment, $headerEnd + 4);

if (str_ends_with($content, "\r\n")) {
$content = substr($content, 0, -2);
}

$parts[] = [self::headers(substr($segment, 0, $headerEnd)), $content];
}

return $parts;
}

/**
* Parses a block of `Header: value` lines into a lower-cased, associative array.
*
* @return array<string, string>
*/
private static function headers(string $rawHeaders): array
{
$headers = [];

foreach (explode("\r\n", $rawHeaders) as $line) {
if (! str_contains($line, ':')) {
continue;
}

[$key, $value] = explode(':', $line, 2);

$headers[strtolower(trim($key))] = trim($value);
}

return $headers;
}

/**
* Splits a field name like `documents[a][]` into `['documents', 'a', '']`,
* mirroring how PHP itself parses bracketed form field names.
*
* @return list<string>
*/
private static function path(string $name): array
{
if (preg_match('/^(?<root>[^\[\]]+)(?<rest>(?:\[[^\]]*\])*)$/', $name, $matches) !== 1) {
return [$name];
}

$path = [$matches['root']];

if ($matches['rest'] !== '') {
preg_match_all('/\[([^\]]*)\]/', $matches['rest'], $segments);

array_push($path, ...$segments[1]);
}

return $path;
}

/**
* Assigns `$value` at the nested location described by `$path`, appending
* a new element whenever a path segment is the empty string (`foo[]`).
*
* @param array<array-key, mixed> $target
* @param list<string> $path
*/
private static function set(array &$target, array $path, mixed $value): void
{
$key = array_shift($path);

if ($key === '') {
if ($path === []) {
$target[] = $value;

return;
}

$target[] = [];
self::set($target[array_key_last($target)], $path, $value);

return;
}

if ($path === []) {
$target[$key] = $value;

return;
}

if (! isset($target[$key]) || ! is_array($target[$key])) {
$target[$key] = [];
}

self::set($target[$key], $path, $value);
}

/**
* Writes an uploaded file's content to a temporary file and assigns its
* `$_FILES`-shaped descriptor at the nested location described by `$path`.
*
* @param array<array-key, mixed> $files
* @param list<string> $path
*/
private static function setFile(array &$files, array $path, string $filename, ?string $type, string $content): void
{
if ($filename === '') {
self::set($files, $path, [
'name' => '',
'type' => '',
'tmp_name' => '',
'error' => \UPLOAD_ERR_NO_FILE,
'size' => 0,
]);

return;
}

$tmpName = tempnam(sys_get_temp_dir(), 'pest_upload_');

if ($tmpName === false) {
return;
}

file_put_contents($tmpName, $content);

self::set($files, $path, [
'name' => $filename,
'type' => $type ?? 'application/octet-stream',
'tmp_name' => $tmpName,
'error' => \UPLOAD_ERR_OK,
'size' => strlen($content),
]);
}
}
111 changes: 111 additions & 0 deletions tests/Unit/Support/MultipartFormDataParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

use Pest\Browser\Support\MultipartFormDataParser;

function multipartBody(string $boundary, array $lines): string
{
return implode("\r\n", $lines)
."\r\n--{$boundary}--\r\n";
}

it('parses plain text fields', function (): void {
$boundary = 'Boundary123';
$body = multipartBody($boundary, [
"--{$boundary}",
'Content-Disposition: form-data; name="type"',
'',
'municipal_valuation',
"--{$boundary}",
'Content-Disposition: form-data; name="notes"',
'',
'multi-line' . "\r\n" . 'value',
]);

[$parameters, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}");

expect($parameters)->toBe([
'type' => 'municipal_valuation',
'notes' => "multi-line\r\nvalue",
])->and($files)->toBe([]);
});

it('parses a single uploaded file into a Symfony-compatible descriptor', function (): void {
$boundary = 'Boundary123';
$content = "%PDF-1.4\nfake pdf bytes";
$body = multipartBody($boundary, [
"--{$boundary}",
'Content-Disposition: form-data; name="type"',
'',
'municipal_valuation',
"--{$boundary}",
'Content-Disposition: form-data; name="file"; filename="valuation.pdf"',
'Content-Type: application/pdf',
'',
$content,
]);

[$parameters, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}");

expect($parameters)->toBe(['type' => 'municipal_valuation'])
->and($files)->toHaveKey('file')
->and($files['file']['name'])->toBe('valuation.pdf')
->and($files['file']['type'])->toBe('application/pdf')
->and($files['file']['error'])->toBe(\UPLOAD_ERR_OK)
->and($files['file']['size'])->toBe(strlen($content))
->and(file_get_contents($files['file']['tmp_name']))->toBe($content);
});

it('marks an empty file input as UPLOAD_ERR_NO_FILE', function (): void {
$boundary = 'Boundary123';
$body = multipartBody($boundary, [
"--{$boundary}",
'Content-Disposition: form-data; name="avatar"; filename=""',
'Content-Type: application/octet-stream',
'',
'',
]);

[, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}");

expect($files['avatar']['error'])->toBe(\UPLOAD_ERR_NO_FILE)
->and($files['avatar']['tmp_name'])->toBe('');
});

it('collects repeated bracketed fields into a list, mirroring PHP\'s own form parsing', function (): void {
$boundary = 'Boundary123';
$body = multipartBody($boundary, [
"--{$boundary}",
'Content-Disposition: form-data; name="tags[]"',
'',
'roof',
"--{$boundary}",
'Content-Disposition: form-data; name="tags[]"',
'',
'damp',
"--{$boundary}",
'Content-Disposition: form-data; name="documents[]"; filename="a.png"',
'Content-Type: image/png',
'',
'aaa',
"--{$boundary}",
'Content-Disposition: form-data; name="documents[]"; filename="b.png"',
'Content-Type: image/png',
'',
'bbb',
]);

[$parameters, $files] = MultipartFormDataParser::parse($body, "multipart/form-data; boundary={$boundary}");

expect($parameters)->toBe(['tags' => ['roof', 'damp']])
->and($files['documents'])->toHaveCount(2)
->and($files['documents'][0]['name'])->toBe('a.png')
->and($files['documents'][1]['name'])->toBe('b.png');
});

it('returns empty parameters and files when the content type has no boundary', function (): void {
[$parameters, $files] = MultipartFormDataParser::parse('irrelevant', 'multipart/form-data');

expect($parameters)->toBe([])->and($files)->toBe([]);
});