Skip to content
Merged
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
331 changes: 311 additions & 20 deletions src/http_server_class.c

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions src/static/static_handler_class.c
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ static inline http_static_handler_shared_t *shared_from_mount(http_static_handle
return (http_static_handler_shared_t *)mount;
}

/* Destructor for persistent HashTable string values. ZVAL_PTR_DTOR goes
* through rc_dtor_func → zend_string_destroy, which asserts the string
* is non-persistent. Persistent string values must be released via
* zend_string_release_ex(s, 1) instead. Used by extra_headers and
* mime_overrides on the SHARED snapshot side. */
static void persistent_str_zval_dtor(zval *zv)
{
if (Z_TYPE_P(zv) == IS_STRING) {
zend_string_release_ex(Z_STR_P(zv), /*persistent*/ 1);
}
}

static zend_string *zstr_dup_persistent(const zend_string *src)
{
if (src == NULL || ZSTR_LEN(src) == 0) {
Expand Down Expand Up @@ -275,7 +287,7 @@ http_static_handler_t *http_static_handler_freeze(const http_static_handler_t *d
if (draft->extra_headers != NULL && zend_hash_num_elements(draft->extra_headers) > 0) {
m->extra_headers = pemalloc(sizeof(HashTable), 1);
zend_hash_init(m->extra_headers, zend_hash_num_elements(draft->extra_headers), NULL,
ZVAL_PTR_DTOR, /*persistent*/ 1);
persistent_str_zval_dtor, /*persistent*/ 1);
zend_string *k;
zval *v;
ZEND_HASH_MAP_FOREACH_STR_KEY_VAL(draft->extra_headers, k, v)
Expand All @@ -297,7 +309,7 @@ http_static_handler_t *http_static_handler_freeze(const http_static_handler_t *d
if (draft->mime_overrides != NULL && zend_hash_num_elements(draft->mime_overrides) > 0) {
m->mime_overrides = pemalloc(sizeof(HashTable), 1);
zend_hash_init(m->mime_overrides, zend_hash_num_elements(draft->mime_overrides), NULL,
ZVAL_PTR_DTOR, /*persistent*/ 1);
persistent_str_zval_dtor, /*persistent*/ 1);
zend_string *k;
zval *v;
ZEND_HASH_MAP_FOREACH_STR_KEY_VAL(draft->mime_overrides, k, v)
Expand Down
70 changes: 70 additions & 0 deletions tests/phpt/multipart/012-rfc5987-filename.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
--TEST--
Multipart: RFC 5987 filename* — percent-decoded UTF-8 filename + precedence over filename=
--EXTENSIONS--
true_async_server
--FILE--
<?php
/* "файл.txt" UTF-8 bytes percent-encoded per RFC 5987 ext-value.
* The legacy `filename=` carries a degraded ASCII form; per RFC 5987
* §4.2 (and our parser), the `filename*` value takes precedence. */
$utf8 = "файл.txt";
$encoded = "%D1%84%D0%B0%D0%B9%D0%BB.txt";

$cases = [
/* [content-disposition value, expected client filename] */
"filename* alone, UTF-8''" =>
["form-data; name=\"a\"; filename*=UTF-8''$encoded", $utf8],
"filename* + filename, * wins" =>
["form-data; name=\"b\"; filename=\"fallback.txt\"; filename*=UTF-8''$encoded", $utf8],
/* RFC 5987 lang segment between the two single quotes — must be skipped. */
"filename* with language tag" =>
["form-data; name=\"c\"; filename*=UTF-8'ru'$encoded", $utf8],
/* Invalid ext-value (missing both single quotes): decoder returns
* NULL, processor falls back to the plain `filename=` token. */
"filename* malformed → fallback" =>
["form-data; name=\"d\"; filename=\"plain.txt\"; filename*=garbage", "plain.txt"],
/* Invalid %XX inside ext-value — decoder passes the bytes through
* verbatim (decoder docstring guarantees this). */
"filename* with bad %XX passthrough" =>
["form-data; name=\"e\"; filename*=UTF-8''bad%ZZok", "bad%ZZok"],
];

$body = '';
foreach ($cases as $cd) {
[$disp, $_] = $cd;
$body .= "-----b\r\n" .
"Content-Disposition: $disp\r\n" .
"Content-Type: application/octet-stream\r\n" .
"\r\n" .
"x\r\n";
}
$body .= "-----b--\r\n";

$req_str = "POST /u HTTP/1.1\r\n" .
"Host: t\r\n" .
"Content-Type: multipart/form-data; boundary=---b\r\n" .
"Content-Length: " . strlen($body) . "\r\n\r\n" . $body;

$req = TrueAsync\http_parse_request($req_str);
$files = $req->getFiles();

foreach ($cases as $label => [$_, $expected]) {
$name = substr($label, 0, 1); // first letter — matches name="a".."e" above
/* Map case index → field name by reusing a→e ordering. */
}

$labels = ['a','b','c','d','e'];
$i = 0;
foreach ($cases as $label => [$_, $expected]) {
$f = $files[$labels[$i]] ?? null;
$got = $f ? $f->getClientFilename() : '<null>';
echo $label, " → ", $got, " | ", ($got === $expected ? "OK" : "MISMATCH expected=$expected"), "\n";
$i++;
}
?>
--EXPECT--
filename* alone, UTF-8'' → файл.txt | OK
filename* + filename, * wins → файл.txt | OK
filename* with language tag → файл.txt | OK
filename* malformed → fallback → plain.txt | OK
filename* with bad %XX passthrough → bad%ZZok | OK
115 changes: 115 additions & 0 deletions tests/phpt/multipart/013-edge-lf-and-preamble.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
--TEST--
Multipart: LF-only line endings + RFC 2046 preamble/epilogue + long-boundary edges
--EXTENSIONS--
true_async_server
--FILE--
<?php
/* RFC 2046 §5.1.1 allows the parser to tolerate LF-only line endings
* (some legacy / handwritten clients still emit them) and arbitrary
* preamble/epilogue text around the part list. Exercises four parser
* arms that the CRLF-only tests in 001-009 don't reach:
* 1. LF-only after the boundary (BOUNDARY_ALMOST_DONE → LF branch)
* 2. LF-only ending a header value
* 3. Preamble text before the first boundary (MP_STATE_START skip)
* 4. Epilogue text after the closing "--" (MP_STATE_END skip)
*
* Plus boundary-length edges: minimum (1 char) and near-maximum. */

function parse(string $req_str): array {
$r = TrueAsync\http_parse_request($req_str);
if ($r === false) return ['parsed' => false];
return ['parsed' => true, 'files' => $r->getFiles(), 'post' => $r->getPost()];
}

/* ----- 1+2: LF-only terminators throughout the multipart body ----- */
$bnd = '---x';
$body = "--$bnd\n"
. "Content-Disposition: form-data; name=\"lfname\"\n"
. "\n"
. "lf-value\n"
. "--$bnd--\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "lf-only: parsed=", $r['parsed'] ? 'yes' : 'no',
" post-lfname=", $r['post']['lfname'] ?? '<missing>', "\n";

/* ----- 3: RFC 2046 preamble — bytes before the first boundary ----- */
$preamble = "This is a multipart message in MIME format.\r\nIgnore this preamble.\r\n";
$body = $preamble
. "--$bnd\r\n"
. "Content-Disposition: form-data; name=\"a\"\r\n\r\n"
. "value-a\r\n"
. "--$bnd--\r\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "preamble: parsed=", $r['parsed'] ? 'yes' : 'no',
" post-a=", $r['post']['a'] ?? '<missing>', "\n";

/* ----- 4: RFC 2046 epilogue — bytes after the closing boundary ----- */
$body = "--$bnd\r\n"
. "Content-Disposition: form-data; name=\"b\"\r\n\r\n"
. "value-b\r\n"
. "--$bnd--\r\n"
. "Trailing epilogue text that must be ignored.\r\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "epilogue: parsed=", $r['parsed'] ? 'yes' : 'no',
" post-b=", $r['post']['b'] ?? '<missing>', "\n";

/* ----- Single-char boundary (smallest legal) ----- */
$bnd1 = 'Y';
$body = "--$bnd1\r\n"
. "Content-Disposition: form-data; name=\"s\"\r\n\r\n"
. "short\r\n"
. "--$bnd1--\r\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd1\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "one-char-boundary: parsed=", $r['parsed'] ? 'yes' : 'no',
" post-s=", $r['post']['s'] ?? '<missing>', "\n";

/* ----- 60-char boundary (RFC max is 70; well within the parser's
* MULTIPART_MAX_BOUNDARY_LEN). ----- */
$bnd_long = str_repeat('Ab9-', 15); // 60 chars
$body = "--$bnd_long\r\n"
. "Content-Disposition: form-data; name=\"l\"\r\n\r\n"
. "value-l\r\n"
. "--$bnd_long--\r\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd_long\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "long-boundary: parsed=", $r['parsed'] ? 'yes' : 'no',
" post-l=", $r['post']['l'] ?? '<missing>', "\n";

/* ----- Boundary-like prefix in body that almost-matches but diverges
* (exercises PART_DATA_BOUNDARY mismatch → flush_lookbehind). ----- */
$body = "--$bnd\r\n"
. "Content-Disposition: form-data; name=\"trap\"\r\n\r\n"
. "see what happens with --$bnd-but-not-quite inside the body\r\n"
. "--$bnd--\r\n";
$req = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$bnd\r\n"
. "Content-Length: " . strlen($body) . "\r\n\r\n" . $body;
$r = parse($req);
echo "near-miss-boundary: parsed=", $r['parsed'] ? 'yes' : 'no',
" trap-contains-but-not-quite=",
(isset($r['post']['trap']) && str_contains($r['post']['trap'], 'but-not-quite')) ? 'yes' : 'no', "\n";

echo "done\n";
?>
--EXPECT--
lf-only: parsed=yes post-lfname=lf-value
preamble: parsed=yes post-a=value-a
epilogue: parsed=yes post-b=value-b
one-char-boundary: parsed=yes post-s=short
long-boundary: parsed=yes post-l=value-l
near-miss-boundary: parsed=yes trap-contains-but-not-quite=yes
done
90 changes: 90 additions & 0 deletions tests/phpt/multipart/014-uploaded-file-error-states.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
--TEST--
Multipart: UploadedFile error states — INVALID_NAME, NO_FILE + getters on errored files
--EXTENSIONS--
true_async_server
--FILE--
<?php
/* Exercises uploaded_file.c getter branches on a file in error state:
* - getSize() → null when error != OK
* - getStream() → returns null on error (only throws on already-moved)
* - moveTo() → throws with "upload error %d" message
* - isValid() → false
* - isReady() → false
*
* Plus getClientCharset() (covered nowhere else in the suite). */

function build_request(string $disp, string $type = 'application/octet-stream', string $body = 'x'): string
{
$b = '---bnd';
$part = "--$b\r\nContent-Disposition: $disp\r\nContent-Type: $type\r\n\r\n$body\r\n--$b--\r\n";
return "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$b\r\n"
. "Content-Length: " . strlen($part) . "\r\n\r\n" . $part;
}

/* MP_UPLOAD_ERR_INVALID_NAME (=101) via path-traversal in filename */
$req = TrueAsync\http_parse_request(build_request(
'form-data; name="x"; filename="../etc/passwd"'));
$f = $req->getFile('x');
echo "traversal:err=", $f->getError(),
" size=", var_export($f->getSize(), true),
" valid=", $f->isValid() ? 1 : 0,
" ready=", $f->isReady() ? 1 : 0, "\n";

$s = $f->getStream();
echo "getStream: ", $s === null ? 'null' : 'resource', "\n";

try { $f->moveTo('/tmp/x'); echo "moveTo: NO_THROW\n"; }
catch (\RuntimeException $e) { echo "moveTo: ", $e->getMessage(), "\n"; }

/* MP_UPLOAD_ERR_INVALID_NAME via absolute path */
$req = TrueAsync\http_parse_request(build_request(
'form-data; name="y"; filename="/abs/path"'));
$f = $req->getFile('y');
echo "abs-path:err=", $f->getError(), "\n";

/* MP_UPLOAD_ERR_INVALID_NAME via overlong filename (> 4096) */
$long = str_repeat('a', 5000);
$req = TrueAsync\http_parse_request(build_request(
"form-data; name=\"z\"; filename=\"$long\""));
$f = $req->getFile('z');
echo "long-name:err=", $f->getError(), "\n";

/* MP_UPLOAD_ERR_NO_FILE via filename="" (RFC 7578 §4.2 — empty filename
* is the way browsers signal "no file selected" for <input type=file>) */
$req = TrueAsync\http_parse_request(build_request(
'form-data; name="empty"; filename=""'));
$f = $req->getFile('empty');
echo "empty-name:err=", $f->getError(),
" ready=", $f->isReady() ? 1 : 0, "\n";

/* getClientCharset — extracted from Content-Type "...; charset=utf-8" */
$req = TrueAsync\http_parse_request(build_request(
'form-data; name="t"; filename="a.txt"',
'text/plain; charset=utf-8',
'hello'));
$f = $req->getFile('t');
echo "charset:err=", $f->getError(),
" ct=", $f->getClientMediaType(),
" cs=", var_export($f->getClientCharset(), true), "\n";

/* No-charset → getClientCharset returns null. */
$req = TrueAsync\http_parse_request(build_request(
'form-data; name="u"; filename="a.txt"',
'text/plain',
'hi'));
$f = $req->getFile('u');
echo "no-charset:cs=", var_export($f->getClientCharset(), true), "\n";

echo "done\n";
?>
--EXPECTF--
traversal:err=101 size=NULL valid=0 ready=0
getStream: null
moveTo: Cannot move file: upload error 101
abs-path:err=101
long-name:err=101
empty-name:err=4 ready=0
charset:err=0 ct=text/plain cs='utf-8'
no-charset:cs=NULL
done
72 changes: 72 additions & 0 deletions tests/phpt/multipart/015-uploaded-file-move-failures.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
--TEST--
Multipart: UploadedFile::moveTo() — copy-fallback failure + mkdir failure
--EXTENSIONS--
true_async_server
--SKIPIF--
<?php if (PHP_OS_FAMILY === 'Windows') die("skip POSIX-only paths"); ?>
--FILE--
<?php
/* Exercises uploaded_file.c moveTo failure tails:
* - L274-280: stat(dir) fails AND mkdir_recursive fails →
* "Cannot create directory"
* - L300-309: rename fails, copy_file fails too →
* "Failed to move file"
*
* Uses /proc/1 (always exists, never writable by user) for the unwritable
* parent + /dev/full/x (existing non-dir parent so stat OK but write to
* a path *inside* it errors). */

/* Holds onto the HttpRequest so the tmp file isn't reaped at scope exit. */
function parse_simple_upload(string $content = 'hello'): array {
$b = '---bnd';
$part = "--$b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.txt\"\r\n"
. "Content-Type: text/plain\r\n\r\n$content\r\n--$b--\r\n";
$req_str = "POST / HTTP/1.1\r\nHost: t\r\n"
. "Content-Type: multipart/form-data; boundary=$b\r\n"
. "Content-Length: " . strlen($part) . "\r\n\r\n" . $part;
$req = TrueAsync\http_parse_request($req_str);
return [$req, $req->getFile('f')];
}

function expect_runtime(string $label, callable $fn): void
{
try {
$fn();
echo "$label: NO-THROW\n";
} catch (\RuntimeException $e) {
$msg = $e->getMessage();
/* Print stable prefix only — actual errno-strings vary by libc. */
$head = preg_replace('/:.*/', '', $msg);
echo "$label: $head\n";
}
}

/* CASE 1: parent dir doesn't exist AND can't be mkdir'd
* (/proc is virtualfs, mkdir always denied). */
[$req1, $f1] = parse_simple_upload();
expect_runtime('mkdir-fail',
fn() => $f1->moveTo('/proc/nonexistent-dir-' . bin2hex(random_bytes(4)) . '/x.txt'));

/* CASE 2: parent "dir" actually exists but isn't a directory; rename
* fails (ENOTDIR), copy fallback fopen() fails too →
* "Failed to move file". /dev/null is a char device existing on every
* POSIX box. */
[$req2, $f2] = parse_simple_upload();
expect_runtime('move-fail',
fn() => $f2->moveTo('/dev/null/inside-char-device.txt'));

/* CASE 3: control — successful move (sanity check that the above
* triggered the failure tails, not some earlier validation). */
[$req3, $f3] = parse_simple_upload('control-body');
$dest = sys_get_temp_dir() . '/upl-ctrl-' . getmypid() . '-' . bin2hex(random_bytes(4));
$f3->moveTo($dest);
echo "control: ", file_get_contents($dest), "\n";
@unlink($dest);

echo "done\n";
?>
--EXPECTF--
mkdir-fail: Cannot create directory
move-fail: %s
control: control-body
done
Loading
Loading