From 8eabdd4ac5615ebfd59b96ae37c8b7ed2ca96d77 Mon Sep 17 00:00:00 2001 From: Yorda Date: Fri, 16 Jan 2026 15:55:34 -0700 Subject: [PATCH] Fix PSR-18 client memory management & harden UploadedFile::moveTo() security Memory Management: - Convert malloc/free/strdup/realloc to Zend equivalents (emalloc/efree/estrdup/erealloc) - Affects: client.c, curl_easy.c, curl_multi_pool.c, curl_worker.c, request_data.c, response_data.c - Ensures proper PHP memory manager integration and request-scoped cleanup - Fixes potential memory tracking issues under PHP's memory_limit Security Hardening (UploadedFile::moveTo): - Add null byte injection prevention (path truncation attack) - Require absolute paths to prevent ambiguity - Improve traversal detection: check for /../ sequences, not just ".." substring - Add realpath() validation to resolve symlinks and verify directory exists - Handle root directory paths (/filename) correctly --- Signalforge/Http/Request.stub.php | 4 +- src/client/client.c | 48 +++++----- src/client/curl_easy.c | 38 ++++---- src/client/curl_multi_pool.c | 60 ++++++------ src/client/curl_worker.c | 48 +++++----- src/client/request_data.c | 70 +++++++------- src/client/response_data.c | 22 +++-- src/uploadedfile.c | 80 +++++++++++++--- tests/015_uploadedfile_moveto_basic.phpt | 69 ++++++++++++++ tests/016_uploadedfile_moveto_traversal.phpt | 80 ++++++++++++++++ ...017_uploadedfile_moveto_absolute_path.phpt | 61 ++++++++++++ tests/018_uploadedfile_moveto_empty_path.phpt | 61 ++++++++++++ tests/019_uploadedfile_error_codes.phpt | 94 +++++++++++++++++++ tests/020_uploadedfile_getstream.phpt | 78 +++++++++++++++ tests/021_uploadedfile_psr17_create.phpt | 59 ++++++++++++ tests/022_uploadedfile_client_filename.phpt | 81 ++++++++++++++++ tests/023_uploadedfile_getsize.phpt | 74 +++++++++++++++ ...4_uploadedfile_moveto_nonexistent_dir.phpt | 62 ++++++++++++ tests/047_uri_empty_components.phpt | 63 +++++++++++++ tests/048_uri_special_chars.phpt | 48 ++++++++++ tests/049_uri_normalization.phpt | 52 ++++++++++ tests/050_uri_with_immutability.phpt | 77 +++++++++++++++ tests/051_uri_port_edge_cases.phpt | 59 ++++++++++++ 23 files changed, 1239 insertions(+), 149 deletions(-) create mode 100644 tests/015_uploadedfile_moveto_basic.phpt create mode 100644 tests/016_uploadedfile_moveto_traversal.phpt create mode 100644 tests/017_uploadedfile_moveto_absolute_path.phpt create mode 100644 tests/018_uploadedfile_moveto_empty_path.phpt create mode 100644 tests/019_uploadedfile_error_codes.phpt create mode 100644 tests/020_uploadedfile_getstream.phpt create mode 100644 tests/021_uploadedfile_psr17_create.phpt create mode 100644 tests/022_uploadedfile_client_filename.phpt create mode 100644 tests/023_uploadedfile_getsize.phpt create mode 100644 tests/024_uploadedfile_moveto_nonexistent_dir.phpt create mode 100644 tests/047_uri_empty_components.phpt create mode 100644 tests/048_uri_special_chars.phpt create mode 100644 tests/049_uri_normalization.phpt create mode 100644 tests/050_uri_with_immutability.phpt create mode 100644 tests/051_uri_port_edge_cases.phpt diff --git a/Signalforge/Http/Request.stub.php b/Signalforge/Http/Request.stub.php index afeb099..90ecba9 100644 --- a/Signalforge/Http/Request.stub.php +++ b/Signalforge/Http/Request.stub.php @@ -167,9 +167,9 @@ public function withMethod(string $method): static {} /** * Retrieves the URI instance. * - * @return string URI as string (basic implementation) + * @return \Psr\Http\Message\UriInterface URI instance */ - public function getUri(): string {} + public function getUri(): \Psr\Http\Message\UriInterface {} /** * Returns an instance with the provided URI. diff --git a/src/client/client.c b/src/client/client.c index 3274703..6dac621 100644 --- a/src/client/client.c +++ b/src/client/client.c @@ -119,18 +119,18 @@ static void signalforge_client_free_object(zend_object *object) { if (client->config) { if (client->config->proxy) { - free(client->config->proxy); + efree(client->config->proxy); } if (client->config->user_agent) { - free(client->config->user_agent); + efree(client->config->user_agent); } if (client->config->ca_cert) { - free(client->config->ca_cert); + efree(client->config->ca_cert); } if (client->config->retry_config) { signalforge_client_retry_config_destroy(client->config->retry_config); } - free(client->config); + efree(client->config); client->config = NULL; } @@ -196,13 +196,13 @@ static char *extract_method(zval *request_obj) { zend_call_method_with_0_params(Z_OBJ_P(request_obj), Z_OBJCE_P(request_obj), NULL, "getmethod", &method_result); if (Z_TYPE(method_result) == IS_STRING && Z_STRLEN(method_result) > 0) { - char *method = strdup(Z_STRVAL(method_result)); + char *method = estrdup(Z_STRVAL(method_result)); zval_ptr_dtor(&method_result); return method; } zval_ptr_dtor(&method_result); - return strdup("GET"); + return estrdup("GET"); } static char *extract_uri(zval *request_obj) { @@ -215,20 +215,20 @@ static char *extract_uri(zval *request_obj) { zend_call_method_with_0_params(Z_OBJ(uri_result), Z_OBJCE(uri_result), NULL, "__tostring", &str_result); if (Z_TYPE(str_result) == IS_STRING && Z_STRLEN(str_result) > 0) { - char *uri = strdup(Z_STRVAL(str_result)); + char *uri = estrdup(Z_STRVAL(str_result)); zval_ptr_dtor(&str_result); zval_ptr_dtor(&uri_result); return uri; } zval_ptr_dtor(&str_result); } else if (Z_TYPE(uri_result) == IS_STRING && Z_STRLEN(uri_result) > 0) { - char *uri = strdup(Z_STRVAL(uri_result)); + char *uri = estrdup(Z_STRVAL(uri_result)); zval_ptr_dtor(&uri_result); return uri; } zval_ptr_dtor(&uri_result); - return strdup("http://localhost/"); + return estrdup("http://localhost/"); } static void extract_headers(zval *request_obj, zval *headers_array) { @@ -256,7 +256,7 @@ static void extract_body(zval *request_obj, char **body, size_t *body_len) { zend_string *str = zval_get_string(&body_result); if (str && ZSTR_LEN(str) > 0) { - *body = malloc(ZSTR_LEN(str)); + *body = emalloc(ZSTR_LEN(str)); if (*body) { memcpy(*body, ZSTR_VAL(str), ZSTR_LEN(str)); *body_len = ZSTR_LEN(str); @@ -264,7 +264,7 @@ static void extract_body(zval *request_obj, char **body, size_t *body_len) { } zend_string_release(str); } else if (Z_TYPE(body_result) == IS_STRING && Z_STRLEN(body_result) > 0) { - *body = malloc(Z_STRLEN(body_result)); + *body = emalloc(Z_STRLEN(body_result)); if (*body) { memcpy(*body, Z_STRVAL(body_result), Z_STRLEN(body_result)); *body_len = Z_STRLEN(body_result); @@ -301,9 +301,11 @@ static signalforge_client_request_t *signalforge_client_psr7_extract_request( config ); - free(method); - free(url); - free(body); + efree(method); + efree(url); + if (body) { + efree(body); + } zval_ptr_dtor(&headers); return request; @@ -323,7 +325,7 @@ static PHP_METHOD(SignalforgeClient, __construct) { client_obj = SIGNALFORGE_CLIENT_FROM_ZOBJ(Z_OBJ_P(ZEND_THIS)); - config = calloc(1, sizeof(signalforge_client_config_t)); + config = ecalloc(1, sizeof(signalforge_client_config_t)); if (!config) { zend_throw_exception(signalforge_http_exception_ce, "Failed to allocate memory for configuration", 0); RETURN_THROWS(); @@ -380,13 +382,13 @@ static PHP_METHOD(SignalforgeClient, __construct) { if ((val = zend_hash_str_find(Z_ARRVAL_P(options), "proxy", sizeof("proxy") - 1)) != NULL) { zend_string *proxy_str = zval_get_string(val); - config->proxy = strdup(ZSTR_VAL(proxy_str)); + config->proxy = estrdup(ZSTR_VAL(proxy_str)); zend_string_release(proxy_str); } if ((val = zend_hash_str_find(Z_ARRVAL_P(options), "user_agent", sizeof("user_agent") - 1)) != NULL) { zend_string *ua_str = zval_get_string(val); - config->user_agent = strdup(ZSTR_VAL(ua_str)); + config->user_agent = estrdup(ZSTR_VAL(ua_str)); zend_string_release(ua_str); } @@ -400,7 +402,7 @@ static PHP_METHOD(SignalforgeClient, __construct) { if ((val = zend_hash_str_find(Z_ARRVAL_P(options), "ca_cert", sizeof("ca_cert") - 1)) != NULL) { zend_string *ca_str = zval_get_string(val); - config->ca_cert = strdup(ZSTR_VAL(ca_str)); + config->ca_cert = estrdup(ZSTR_VAL(ca_str)); zend_string_release(ca_str); } @@ -456,7 +458,7 @@ static PHP_METHOD(SignalforgeClient, __construct) { /* Create shared connection cache */ client_obj->share = signalforge_client_share_create(); if (!client_obj->share) { - free(config); + efree(config); client_obj->config = NULL; zend_throw_exception(signalforge_http_exception_ce, "Failed to create shared connection cache", 0); RETURN_THROWS(); @@ -470,7 +472,7 @@ static PHP_METHOD(SignalforgeClient, __construct) { if (!client_obj->thread_pool) { signalforge_client_share_destroy(client_obj->share); client_obj->share = NULL; - free(config); + efree(config); client_obj->config = NULL; zend_throw_exception(signalforge_http_exception_ce, "Failed to create thread pool", 0); RETURN_THROWS(); @@ -534,7 +536,7 @@ static PHP_METHOD(SignalforgeClient, sendRequest) { char *msg_copy = NULL; if (response->error_message) { - msg_copy = strdup(response->error_message); + msg_copy = estrdup(response->error_message); } signalforge_client_response_destroy(response); @@ -555,7 +557,9 @@ static PHP_METHOD(SignalforgeClient, sendRequest) { zend_throw_exception(signalforge_network_exception_ce, detailed_msg, (zend_long)curl_code); - free(msg_copy); + if (msg_copy) { + efree(msg_copy); + } RETURN_THROWS(); } diff --git a/src/client/curl_easy.c b/src/client/curl_easy.c index 4e21d82..8dbd4bf 100644 --- a/src/client/curl_easy.c +++ b/src/client/curl_easy.c @@ -59,7 +59,7 @@ static size_t write_callback(void *ptr, size_t size, size_t nmemb, void *userdat new_capacity = ctx->response_body_len + total_size + 4096; } - char *new_body = realloc(ctx->response_body, new_capacity); + char *new_body = erealloc(ctx->response_body, new_capacity); if (!new_body) { return 0; } @@ -85,7 +85,7 @@ static size_t header_callback(void *ptr, size_t size, size_t nmemb, void *userda new_capacity = ctx->response_headers_len + total_size + 1024; } - char *new_headers = realloc(ctx->response_headers, new_capacity); + char *new_headers = erealloc(ctx->response_headers, new_capacity); if (!new_headers) { return 0; } @@ -125,7 +125,7 @@ void signalforge_curl_parse_headers(signalforge_curl_context_t *ctx) { return; } - ctx->response->headers = malloc(sizeof(signalforge_client_header_t) * header_count); + ctx->response->headers = emalloc(sizeof(signalforge_client_header_t) * header_count); if (!ctx->response->headers) { return; } @@ -140,7 +140,7 @@ void signalforge_curl_parse_headers(signalforge_curl_context_t *ctx) { char *colon = memchr(ptr, ':', line_end - ptr); if (colon) { size_t name_len = colon - ptr; - char *name = malloc(name_len + 1); + char *name = emalloc(name_len + 1); if (name) { memcpy(name, ptr, name_len); name[name_len] = '\0'; @@ -159,7 +159,7 @@ void signalforge_curl_parse_headers(signalforge_curl_context_t *ctx) { value_len--; } - char *value = malloc(value_len + 1); + char *value = emalloc(value_len + 1); if (value) { memcpy(value, value_start, value_len); value[value_len] = '\0'; @@ -168,7 +168,7 @@ void signalforge_curl_parse_headers(signalforge_curl_context_t *ctx) { ctx->response->headers[idx].value = value; idx++; } else { - free(name); + efree(name); } } } @@ -285,13 +285,13 @@ int signalforge_curl_setup( for (size_t i = 0; i < ctx->request->header_count; i++) { size_t header_len = strlen(ctx->request->headers[i].name) + strlen(ctx->request->headers[i].value) + 3; - char *header = malloc(header_len); + char *header = emalloc(header_len); if (header) { snprintf(header, header_len, "%s: %s", ctx->request->headers[i].name, ctx->request->headers[i].value); list = curl_slist_append(list, header); - free(header); + efree(header); } } if (list) { @@ -364,13 +364,17 @@ void signalforge_curl_cleanup_context(signalforge_curl_context_t *ctx) { ctx->header_list = NULL; } - free(ctx->response_body); - ctx->response_body = NULL; + if (ctx->response_body) { + efree(ctx->response_body); + ctx->response_body = NULL; + } ctx->response_body_len = 0; ctx->response_body_capacity = 0; - free(ctx->response_headers); - ctx->response_headers = NULL; + if (ctx->response_headers) { + efree(ctx->response_headers); + ctx->response_headers = NULL; + } ctx->response_headers_len = 0; ctx->response_headers_capacity = 0; @@ -421,13 +425,13 @@ signalforge_client_response_t *signalforge_curl_easy_execute( /* Allocate response buffers */ ctx.response_body_capacity = 4096; - ctx.response_body = malloc(ctx.response_body_capacity); + ctx.response_body = emalloc(ctx.response_body_capacity); ctx.response_headers_capacity = 1024; - ctx.response_headers = malloc(ctx.response_headers_capacity); + ctx.response_headers = emalloc(ctx.response_headers_capacity); if (!ctx.response_body || !ctx.response_headers) { - free(ctx.response_body); - free(ctx.response_headers); + if (ctx.response_body) efree(ctx.response_body); + if (ctx.response_headers) efree(ctx.response_headers); signalforge_client_response_destroy(response); curl_easy_cleanup(curl); return NULL; @@ -500,7 +504,7 @@ signalforge_client_response_t *signalforge_curl_easy_execute( retry_count++; } else { /* No retry - set error message */ - response->error_message = strdup(curl_easy_strerror(result)); + response->error_message = estrdup(curl_easy_strerror(result)); break; } diff --git a/src/client/curl_multi_pool.c b/src/client/curl_multi_pool.c index 0c13aa4..4624e1c 100644 --- a/src/client/curl_multi_pool.c +++ b/src/client/curl_multi_pool.c @@ -38,14 +38,14 @@ signalforge_curl_multi_pool_t *signalforge_curl_multi_pool_create( signalforge_client_config_t *config, int max_concurrent ) { - signalforge_curl_multi_pool_t *pool = calloc(1, sizeof(signalforge_curl_multi_pool_t)); + signalforge_curl_multi_pool_t *pool = ecalloc(1, sizeof(signalforge_curl_multi_pool_t)); if (!pool) { return NULL; } pool->multi_handle = curl_multi_init(); if (!pool->multi_handle) { - free(pool); + efree(pool); return NULL; } @@ -65,10 +65,10 @@ signalforge_curl_multi_pool_t *signalforge_curl_multi_pool_create( /* Initialize response array */ pool->response_capacity = RESPONSE_ARRAY_INITIAL_CAPACITY; - pool->responses = calloc(pool->response_capacity, sizeof(signalforge_client_response_t *)); + pool->responses = ecalloc(pool->response_capacity, sizeof(signalforge_client_response_t *)); if (!pool->responses) { curl_multi_cleanup(pool->multi_handle); - free(pool); + efree(pool); return NULL; } pool->response_count = 0; @@ -126,7 +126,7 @@ static int add_response( ) { if (pool->response_count >= pool->response_capacity) { size_t new_capacity = pool->response_capacity * 2; - signalforge_client_response_t **new_responses = realloc( + signalforge_client_response_t **new_responses = erealloc( pool->responses, new_capacity * sizeof(signalforge_client_response_t *) ); @@ -153,7 +153,7 @@ int signalforge_curl_multi_pool_add( } /* Create active request tracking structure */ - signalforge_multi_active_request_t *active_req = calloc(1, sizeof(signalforge_multi_active_request_t)); + signalforge_multi_active_request_t *active_req = ecalloc(1, sizeof(signalforge_multi_active_request_t)); if (!active_req) { return -1; } @@ -163,31 +163,31 @@ int signalforge_curl_multi_pool_add( active_req->ctx.request = request; active_req->ctx.response = signalforge_client_response_create(); if (!active_req->ctx.response) { - free(active_req); + efree(active_req); return -1; } /* Allocate response buffers */ active_req->ctx.response_body_capacity = 4096; - active_req->ctx.response_body = malloc(active_req->ctx.response_body_capacity); + active_req->ctx.response_body = emalloc(active_req->ctx.response_body_capacity); active_req->ctx.response_headers_capacity = 1024; - active_req->ctx.response_headers = malloc(active_req->ctx.response_headers_capacity); + active_req->ctx.response_headers = emalloc(active_req->ctx.response_headers_capacity); if (!active_req->ctx.response_body || !active_req->ctx.response_headers) { - free(active_req->ctx.response_body); - free(active_req->ctx.response_headers); + if (active_req->ctx.response_body) efree(active_req->ctx.response_body); + if (active_req->ctx.response_headers) efree(active_req->ctx.response_headers); signalforge_client_response_destroy(active_req->ctx.response); - free(active_req); + efree(active_req); return -1; } /* Create curl handle */ CURL *curl = curl_easy_init(); if (!curl) { - free(active_req->ctx.response_body); - free(active_req->ctx.response_headers); + efree(active_req->ctx.response_body); + efree(active_req->ctx.response_headers); signalforge_client_response_destroy(active_req->ctx.response); - free(active_req); + efree(active_req); return -1; } @@ -196,10 +196,10 @@ int signalforge_curl_multi_pool_add( /* Setup curl handle */ if (signalforge_curl_setup(curl, &active_req->ctx, pool->share_handle, pool->config) != 0) { curl_easy_cleanup(curl); - free(active_req->ctx.response_body); - free(active_req->ctx.response_headers); + efree(active_req->ctx.response_body); + efree(active_req->ctx.response_headers); signalforge_client_response_destroy(active_req->ctx.response); - free(active_req); + efree(active_req); return -1; } @@ -209,7 +209,7 @@ int signalforge_curl_multi_pool_add( signalforge_curl_cleanup_context(&active_req->ctx); curl_easy_cleanup(curl); signalforge_client_response_destroy(active_req->ctx.response); - free(active_req); + efree(active_req); return -1; } @@ -243,7 +243,7 @@ static void process_completed( /* Error occurred */ ctx->response->is_error = 1; ctx->response->curl_code = result; - ctx->response->error_message = strdup(curl_easy_strerror(result)); + ctx->response->error_message = estrdup(curl_easy_strerror(result)); /* Still try to get any partial response data */ curl_easy_getinfo(curl_handle, CURLINFO_RESPONSE_CODE, &ctx->response->http_code); @@ -263,10 +263,14 @@ static void process_completed( } /* Free temporary buffers (body/headers already transferred or error) */ - free(ctx->response_body); - ctx->response_body = NULL; - free(ctx->response_headers); - ctx->response_headers = NULL; + if (ctx->response_body) { + efree(ctx->response_body); + ctx->response_body = NULL; + } + if (ctx->response_headers) { + efree(ctx->response_headers); + ctx->response_headers = NULL; + } /* Add response to results */ add_response(pool, ctx->response); @@ -280,7 +284,7 @@ static void process_completed( /* Remove from active list and free */ remove_active_request(pool, active_req); - free(active_req); + efree(active_req); } /** @@ -391,7 +395,7 @@ void signalforge_curl_multi_pool_destroy(signalforge_curl_multi_pool_t *pool) { signalforge_client_request_destroy(current->ctx.request); } - free(current); + efree(current); current = next; } @@ -401,9 +405,9 @@ void signalforge_curl_multi_pool_destroy(signalforge_curl_multi_pool_t *pool) { } /* Free response array (but not the responses themselves) */ - free(pool->responses); + efree(pool->responses); - free(pool); + efree(pool); } #endif /* HAVE_SIGNALFORGE_HTTP_CLIENT */ diff --git a/src/client/curl_worker.c b/src/client/curl_worker.c index 8c2ae35..f454d49 100644 --- a/src/client/curl_worker.c +++ b/src/client/curl_worker.c @@ -33,7 +33,7 @@ * Create a new handle pool */ signalforge_curl_handle_pool_t *signalforge_curl_handle_pool_create(void) { - signalforge_curl_handle_pool_t *pool = calloc(1, sizeof(signalforge_curl_handle_pool_t)); + signalforge_curl_handle_pool_t *pool = ecalloc(1, sizeof(signalforge_curl_handle_pool_t)); if (!pool) { return NULL; } @@ -125,7 +125,7 @@ void signalforge_curl_handle_pool_destroy(signalforge_curl_handle_pool_t *pool) } } - free(pool); + efree(pool); } /* Zero-copy request body read callback */ @@ -161,7 +161,7 @@ static size_t write_callback(void *ptr, size_t size, size_t nmemb, void *userdat new_capacity = req->response_body_len + total_size + 4096; } - char *new_body = realloc(req->response_body, new_capacity); + char *new_body = erealloc(req->response_body, new_capacity); if (!new_body) { return 0; } @@ -187,7 +187,7 @@ static size_t header_callback(void *ptr, size_t size, size_t nmemb, void *userda new_capacity = req->response_headers_len + total_size + 1024; } - char *new_headers = realloc(req->response_headers, new_capacity); + char *new_headers = erealloc(req->response_headers, new_capacity); if (!new_headers) { return 0; } @@ -227,7 +227,7 @@ static void parse_response_headers(signalforge_active_request_t *req) { return; } - req->response->headers = malloc(sizeof(signalforge_client_header_t) * header_count); + req->response->headers = emalloc(sizeof(signalforge_client_header_t) * header_count); if (!req->response->headers) { return; } @@ -242,7 +242,7 @@ static void parse_response_headers(signalforge_active_request_t *req) { char *colon = memchr(ptr, ':', line_end - ptr); if (colon) { size_t name_len = colon - ptr; - char *name = malloc(name_len + 1); + char *name = emalloc(name_len + 1); if (name) { memcpy(name, ptr, name_len); name[name_len] = '\0'; @@ -261,7 +261,7 @@ static void parse_response_headers(signalforge_active_request_t *req) { value_len--; } - char *value = malloc(value_len + 1); + char *value = emalloc(value_len + 1); if (value) { memcpy(value, value_start, value_len); value[value_len] = '\0'; @@ -270,7 +270,7 @@ static void parse_response_headers(signalforge_active_request_t *req) { req->response->headers[idx].value = value; idx++; } else { - free(name); + efree(name); } } } @@ -370,11 +370,15 @@ static int setup_curl_handle( if (req->request->headers && req->request->header_count > 0) { struct curl_slist *list = NULL; for (size_t i = 0; i < req->request->header_count; i++) { - char *header = malloc(strlen(req->request->headers[i].name) + strlen(req->request->headers[i].value) + 3); + size_t header_len = strlen(req->request->headers[i].name) + + strlen(req->request->headers[i].value) + 3; + char *header = emalloc(header_len); if (header) { - sprintf(header, "%s: %s", req->request->headers[i].name, req->request->headers[i].value); + snprintf(header, header_len, "%s: %s", + req->request->headers[i].name, + req->request->headers[i].value); list = curl_slist_append(list, header); - free(header); + efree(header); } } if (list) { @@ -437,10 +441,10 @@ static void cleanup_active_request(signalforge_active_request_t *req, signalforg req->header_list = NULL; } - free(req->response_body); + efree(req->response_body); req->response_body = NULL; - free(req->response_headers); + efree(req->response_headers); req->response_headers = NULL; req->body_read_ctx.data = NULL; @@ -476,15 +480,15 @@ void *signalforge_client_worker_thread(void *arg) { signalforge_client_queue_pop(worker->request_queue, 100); if (request) { - signalforge_active_request_t *active_req = calloc(1, sizeof(signalforge_active_request_t)); + signalforge_active_request_t *active_req = ecalloc(1, sizeof(signalforge_active_request_t)); if (active_req) { active_req->request = request; active_req->response = signalforge_client_response_create(); active_req->response_body_capacity = 4096; - active_req->response_body = malloc(active_req->response_body_capacity); + active_req->response_body = emalloc(active_req->response_body_capacity); active_req->response_headers_capacity = 1024; - active_req->response_headers = malloc(active_req->response_headers_capacity); + active_req->response_headers = emalloc(active_req->response_headers_capacity); if (active_req->response && active_req->response_body && active_req->response_headers) { CURLSH *share = signalforge_client_share_get_handle(pool->share); @@ -529,7 +533,7 @@ void *signalforge_client_worker_thread(void *arg) { if (msg->data.result != CURLE_OK) { active_req->response->is_error = 1; - active_req->response->error_message = strdup(curl_easy_strerror(msg->data.result)); + active_req->response->error_message = estrdup(curl_easy_strerror(msg->data.result)); switch (msg->data.result) { case CURLE_SSL_CONNECT_ERROR: @@ -586,7 +590,7 @@ void *signalforge_client_worker_thread(void *arg) { cleanup_active_request(active_req, handle_pool, discard_handle); signalforge_client_request_destroy(active_req->request); - free(active_req); + efree(active_req); } else { active_req->response->is_error = 1; active_req->response->curl_code = CURLE_FAILED_INIT; @@ -594,15 +598,15 @@ void *signalforge_client_worker_thread(void *arg) { cleanup_active_request(active_req, handle_pool, 0); signalforge_client_request_destroy(active_req->request); - free(active_req); + efree(active_req); } } else { if (active_req->response) { signalforge_client_response_destroy(active_req->response); } - free(active_req->response_body); - free(active_req->response_headers); - free(active_req); + efree(active_req->response_body); + efree(active_req->response_headers); + efree(active_req); signalforge_client_request_destroy(request); } } else { diff --git a/src/client/request_data.c b/src/client/request_data.c index f8c845d..c2ddcf2 100644 --- a/src/client/request_data.c +++ b/src/client/request_data.c @@ -33,8 +33,8 @@ signalforge_client_request_t *signalforge_client_request_create( size_t body_len, signalforge_client_config_t *config ) { - /* Use calloc for zero-initialization */ - signalforge_client_request_t *request = calloc(1, sizeof(signalforge_client_request_t)); + /* Use ecalloc for zero-initialization */ + signalforge_client_request_t *request = ecalloc(1, sizeof(signalforge_client_request_t)); if (!request) { return NULL; } @@ -44,19 +44,19 @@ signalforge_client_request_t *signalforge_client_request_create( /* Copy method */ if (method) { - request->method = strdup(method); + request->method = estrdup(method); if (!request->method) { - free(request); + efree(request); return NULL; } } /* Copy URL */ if (url) { - request->url = strdup(url); + request->url = estrdup(url); if (!request->url) { - free(request->method); - free(request); + efree(request->method); + efree(request); return NULL; } } @@ -67,11 +67,11 @@ signalforge_client_request_t *signalforge_client_request_create( request->header_count = zend_hash_num_elements(ht); if (request->header_count > 0) { - request->headers = malloc(sizeof(signalforge_client_header_t) * request->header_count); + request->headers = emalloc(sizeof(signalforge_client_header_t) * request->header_count); if (!request->headers) { - free(request->url); - free(request->method); - free(request); + efree(request->url); + efree(request->method); + efree(request); return NULL; } @@ -85,7 +85,7 @@ signalforge_client_request_t *signalforge_client_request_create( if (Z_TYPE_P(val) == IS_STRING) { /* Simple string value */ - header_value = strdup(Z_STRVAL_P(val)); + header_value = estrdup(Z_STRVAL_P(val)); } else if (Z_TYPE_P(val) == IS_ARRAY) { /* PSR-7 format: array of values - join with ", " */ HashTable *vals = Z_ARRVAL_P(val); @@ -101,7 +101,7 @@ signalforge_client_request_t *signalforge_client_request_create( } ZEND_HASH_FOREACH_END(); if (total_len > 0) { - header_value = malloc(total_len + 1); + header_value = emalloc(total_len + 1); if (header_value) { header_value[0] = '\0'; size_t pos = 0; @@ -125,20 +125,20 @@ signalforge_client_request_t *signalforge_client_request_create( } if (header_value) { - request->headers[idx].name = strdup(ZSTR_VAL(key)); + request->headers[idx].name = estrdup(ZSTR_VAL(key)); request->headers[idx].value = header_value; if (!request->headers[idx].name) { - free(header_value); + efree(header_value); /* Cleanup on error */ for (size_t i = 0; i < idx; i++) { - free(request->headers[i].name); - free(request->headers[i].value); + efree(request->headers[i].name); + efree(request->headers[i].value); } - free(request->headers); - free(request->url); - free(request->method); - free(request); + efree(request->headers); + efree(request->url); + efree(request->method); + efree(request); return NULL; } idx++; @@ -152,16 +152,16 @@ signalforge_client_request_t *signalforge_client_request_create( /* Copy body */ if (body && body_len > 0) { - request->body = malloc(body_len); + request->body = emalloc(body_len); if (!request->body) { for (size_t i = 0; i < request->header_count; i++) { - free(request->headers[i].name); - free(request->headers[i].value); + efree(request->headers[i].name); + efree(request->headers[i].value); } - free(request->headers); - free(request->url); - free(request->method); - free(request); + efree(request->headers); + efree(request->url); + efree(request->method); + efree(request); return NULL; } memcpy(request->body, body, body_len); @@ -182,19 +182,19 @@ void signalforge_client_request_destroy(signalforge_client_request_t *request) { return; } - free(request->method); - free(request->url); + efree(request->method); + efree(request->url); if (request->headers) { for (size_t i = 0; i < request->header_count; i++) { - free(request->headers[i].name); - free(request->headers[i].value); + efree(request->headers[i].name); + efree(request->headers[i].value); } - free(request->headers); + efree(request->headers); } - free(request->body); - free(request); + efree(request->body); + efree(request); } #endif /* HAVE_SIGNALFORGE_HTTP_CLIENT */ diff --git a/src/client/response_data.c b/src/client/response_data.c index bf48c13..9a80996 100644 --- a/src/client/response_data.c +++ b/src/client/response_data.c @@ -29,7 +29,7 @@ extern zend_class_entry *signalforge_response_ce; * Create a new response data structure */ signalforge_client_response_t *signalforge_client_response_create(void) { - return calloc(1, sizeof(signalforge_client_response_t)); + return ecalloc(1, sizeof(signalforge_client_response_t)); } /** @@ -40,18 +40,22 @@ void signalforge_client_response_destroy(signalforge_client_response_t *response return; } - free(response->body); - free(response->error_message); + if (response->body) { + efree(response->body); + } + if (response->error_message) { + efree(response->error_message); + } if (response->headers) { for (size_t i = 0; i < response->header_count; i++) { - free(response->headers[i].name); - free(response->headers[i].value); + if (response->headers[i].name) efree(response->headers[i].name); + if (response->headers[i].value) efree(response->headers[i].value); } - free(response->headers); + efree(response->headers); } - free(response); + efree(response); } /** @@ -120,7 +124,7 @@ zval signalforge_client_create_psr7_response(signalforge_client_response_t *resp } /* Normalize header name to lowercase */ - char *lower_name = strdup(response->headers[i].name); + char *lower_name = estrdup(response->headers[i].name); if (!lower_name) continue; for (char *p = lower_name; *p; p++) { *p = tolower((unsigned char)*p); @@ -139,7 +143,7 @@ zval signalforge_client_create_psr7_response(signalforge_client_response_t *resp zend_hash_str_add(intern->ht_headers, lower_name, strlen(lower_name), &header_array); } - free(lower_name); + efree(lower_name); } /* Set body as string (will be wrapped in Stream when getBody() is called) */ diff --git a/src/uploadedfile.c b/src/uploadedfile.c index 8a02c3a..3f9f4b8 100644 --- a/src/uploadedfile.c +++ b/src/uploadedfile.c @@ -18,6 +18,7 @@ #include #include #include +#include /* ============================================================================ * GLOBAL STATE @@ -452,31 +453,82 @@ PHP_METHOD(Signalforge_Http_UploadedFile, moveTo) RETURN_THROWS(); } - /* Validate target path for security */ - if (strstr(ZSTR_VAL(target_path), "..") != NULL) { + /* Security: Reject paths containing null bytes (truncation attack) */ + if (memchr(ZSTR_VAL(target_path), '\0', ZSTR_LEN(target_path)) != NULL) { + zend_throw_exception(spl_ce_InvalidArgumentException, + "Invalid target path: contains null byte", 0); + RETURN_THROWS(); + } + + /* Security: Require absolute paths to prevent ambiguity */ + if (ZSTR_VAL(target_path)[0] != '/') { + zend_throw_exception(spl_ce_InvalidArgumentException, + "Target path must be absolute (start with /)", 0); + RETURN_THROWS(); + } + + /* Security: Check for directory traversal sequences + * We look for /../ or /.. at end of path, not just ".." substring + * to avoid false positives on legitimate filenames containing ".." + */ + const char *path_str = ZSTR_VAL(target_path); + const char *traverse_check = path_str; + while ((traverse_check = strstr(traverse_check, "/../")) != NULL) { + zend_throw_exception(spl_ce_InvalidArgumentException, + "Invalid target path: contains directory traversal", 0); + RETURN_THROWS(); + } + /* Also check for trailing /.. */ + size_t path_len = ZSTR_LEN(target_path); + if (path_len >= 3 && strcmp(path_str + path_len - 3, "/..") == 0) { zend_throw_exception(spl_ce_InvalidArgumentException, "Invalid target path: contains directory traversal", 0); RETURN_THROWS(); } - /* Check if target directory is writable - safely */ + /* Extract target directory and validate with realpath() + * This resolves symlinks and ensures the directory exists */ char *target_dir = NULL; char *last_slash = NULL; + char resolved_dir[PATH_MAX]; target_dir = estrndup(ZSTR_VAL(target_path), ZSTR_LEN(target_path)); - if (target_dir) { - last_slash = strrchr(target_dir, '/'); - if (last_slash) { - *last_slash = '\0'; - if (access(target_dir, W_OK) != 0) { - efree(target_dir); - zend_throw_exception(spl_ce_RuntimeException, - "Target directory is not writable", 0); - RETURN_THROWS(); - } + if (!target_dir) { + zend_throw_exception(spl_ce_RuntimeException, + "Memory allocation failed", 0); + RETURN_THROWS(); + } + + last_slash = strrchr(target_dir, '/'); + if (last_slash && last_slash != target_dir) { + *last_slash = '\0'; + + /* Use realpath() to get canonical directory path + * This resolves symlinks and ./ sequences */ + if (realpath(target_dir, resolved_dir) == NULL) { + efree(target_dir); + zend_throw_exception(spl_ce_RuntimeException, + "Target directory does not exist or is not accessible", 0); + RETURN_THROWS(); + } + + /* Check if directory is writable */ + if (access(resolved_dir, W_OK) != 0) { + efree(target_dir); + zend_throw_exception(spl_ce_RuntimeException, + "Target directory is not writable", 0); + RETURN_THROWS(); + } + } else if (last_slash == target_dir) { + /* Path is in root directory like /filename */ + if (access("/", W_OK) != 0) { + efree(target_dir); + zend_throw_exception(spl_ce_RuntimeException, + "Root directory is not writable", 0); + RETURN_THROWS(); } - efree(target_dir); } + efree(target_dir); /* Check tmp_name */ if (!intern->tmp_name || ZSTR_LEN(intern->tmp_name) == 0) { diff --git a/tests/015_uploadedfile_moveto_basic.phpt b/tests/015_uploadedfile_moveto_basic.phpt new file mode 100644 index 0000000..8ee1be5 --- /dev/null +++ b/tests/015_uploadedfile_moveto_basic.phpt @@ -0,0 +1,69 @@ +--TEST-- +signalforge_http: UploadedFile moveTo() basic functionality +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/source.tmp'; +$targetFile = $testDir . '/target.txt'; +$testContent = 'Test file content for moveTo'; +file_put_contents($tmpFile, $testContent); + +$_FILES = [ + 'upload' => [ + 'name' => 'document.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($testContent), + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT: Move file to target location +$uploadedFile->moveTo($targetFile); + +// ASSERT: Target file exists with correct content +echo "Target exists: "; +var_dump(file_exists($targetFile)); + +echo "Content preserved: "; +var_dump(file_get_contents($targetFile) === $testContent); + +echo "Source removed: "; +var_dump(!file_exists($tmpFile)); + +// Cleanup +@unlink($targetFile); +@rmdir($testDir); + +echo "moveTo basic test passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Target exists: bool(true) +Content preserved: bool(true) +Source removed: bool(true) +moveTo basic test passed diff --git a/tests/016_uploadedfile_moveto_traversal.phpt b/tests/016_uploadedfile_moveto_traversal.phpt new file mode 100644 index 0000000..aca42a3 --- /dev/null +++ b/tests/016_uploadedfile_moveto_traversal.phpt @@ -0,0 +1,80 @@ +--TEST-- +signalforge_http: UploadedFile moveTo() rejects directory traversal +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/source.tmp'; +file_put_contents($tmpFile, 'test'); + +$_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 4, + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT & ASSERT: Test /../ traversal rejection +echo "Test /../ traversal: "; +try { + $uploadedFile->moveTo($testDir . '/../../../etc/passwd'); + echo "FAIL - should have thrown\n"; +} catch (InvalidArgumentException $e) { + var_dump(strpos($e->getMessage(), 'traversal') !== false); +} + +// Need new request/file for next test +unset($request, $files, $uploadedFile); +gc_collect_cycles(); +file_put_contents($tmpFile, 'test'); + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT & ASSERT: Test trailing /.. rejection +echo "Test trailing /.. : "; +try { + $uploadedFile->moveTo($testDir . '/..'); + echo "FAIL - should have thrown\n"; +} catch (InvalidArgumentException $e) { + var_dump(strpos($e->getMessage(), 'traversal') !== false); +} + +// Cleanup +@unlink($tmpFile); +@rmdir($testDir); + +echo "Traversal rejection tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Test /../ traversal: bool(true) +Test trailing /.. : bool(true) +Traversal rejection tests passed diff --git a/tests/017_uploadedfile_moveto_absolute_path.phpt b/tests/017_uploadedfile_moveto_absolute_path.phpt new file mode 100644 index 0000000..b22c77f --- /dev/null +++ b/tests/017_uploadedfile_moveto_absolute_path.phpt @@ -0,0 +1,61 @@ +--TEST-- +signalforge_http: UploadedFile moveTo() requires absolute paths +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/source.tmp'; +file_put_contents($tmpFile, 'test'); + +$_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 4, + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT & ASSERT: Relative path should be rejected +echo "Relative path rejected: "; +try { + $uploadedFile->moveTo('relative/path/file.txt'); + echo "FAIL - should have thrown\n"; +} catch (InvalidArgumentException $e) { + var_dump(strpos($e->getMessage(), 'absolute') !== false); +} + +// Cleanup +@unlink($tmpFile); +@rmdir($testDir); + +echo "Absolute path requirement test passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Relative path rejected: bool(true) +Absolute path requirement test passed diff --git a/tests/018_uploadedfile_moveto_empty_path.phpt b/tests/018_uploadedfile_moveto_empty_path.phpt new file mode 100644 index 0000000..7e76c73 --- /dev/null +++ b/tests/018_uploadedfile_moveto_empty_path.phpt @@ -0,0 +1,61 @@ +--TEST-- +signalforge_http: UploadedFile moveTo() rejects empty path +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/source.tmp'; +file_put_contents($tmpFile, 'test'); + +$_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 4, + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT & ASSERT: Empty path should be rejected +echo "Empty path rejected: "; +try { + $uploadedFile->moveTo(''); + echo "FAIL - should have thrown\n"; +} catch (InvalidArgumentException $e) { + var_dump(strpos($e->getMessage(), 'empty') !== false); +} + +// Cleanup +@unlink($tmpFile); +@rmdir($testDir); + +echo "Empty path rejection test passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Empty path rejected: bool(true) +Empty path rejection test passed diff --git a/tests/019_uploadedfile_error_codes.phpt b/tests/019_uploadedfile_error_codes.phpt new file mode 100644 index 0000000..bddad90 --- /dev/null +++ b/tests/019_uploadedfile_error_codes.phpt @@ -0,0 +1,94 @@ +--TEST-- +signalforge_http: UploadedFile handles upload error codes correctly +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// Test each PHP upload error constant +$errorCodes = [ + UPLOAD_ERR_OK => 'UPLOAD_ERR_OK', + UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE', + UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE', + UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL', + UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE', + UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR', + UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE', + UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION', +]; + +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); + +foreach ($errorCodes as $code => $name) { + $tmpFile = $testDir . '/error_test.tmp'; + file_put_contents($tmpFile, 'test'); + + $_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => $code, + 'size' => 4, + ], + ]; + + $request = Request::capture(); + $files = $request->getUploadedFiles(); + $uploadedFile = $files['upload']; + + echo "{$name}: "; + var_dump($uploadedFile->getError() === $code); + + // Test that non-OK errors prevent operations + if ($code !== UPLOAD_ERR_OK) { + try { + $uploadedFile->getStream(); + echo " getStream should throw for error\n"; + } catch (Exception $e) { + // Expected + } + + try { + $uploadedFile->moveTo($testDir . '/target.txt'); + echo " moveTo should throw for error\n"; + } catch (RuntimeException $e) { + // Expected + } + } + + @unlink($tmpFile); + unset($request, $files, $uploadedFile); + gc_collect_cycles(); +} + +@rmdir($testDir); +echo "Error code handling tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +UPLOAD_ERR_OK: bool(true) +UPLOAD_ERR_INI_SIZE: bool(true) +UPLOAD_ERR_FORM_SIZE: bool(true) +UPLOAD_ERR_PARTIAL: bool(true) +UPLOAD_ERR_NO_FILE: bool(true) +UPLOAD_ERR_NO_TMP_DIR: bool(true) +UPLOAD_ERR_CANT_WRITE: bool(true) +UPLOAD_ERR_EXTENSION: bool(true) +Error code handling tests passed diff --git a/tests/020_uploadedfile_getstream.phpt b/tests/020_uploadedfile_getstream.phpt new file mode 100644 index 0000000..9d27776 --- /dev/null +++ b/tests/020_uploadedfile_getstream.phpt @@ -0,0 +1,78 @@ +--TEST-- +signalforge_http: UploadedFile getStream() returns valid StreamInterface +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/stream_test.tmp'; +$testContent = 'Stream test content with special chars: <>&"\''; +file_put_contents($tmpFile, $testContent); + +$_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($testContent), + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT: Get stream +$stream = $uploadedFile->getStream(); + +// ASSERT: Stream implements PSR-7 interface +echo "Implements StreamInterface: "; +var_dump($stream instanceof StreamInterface); + +// ASSERT: Stream is readable +echo "Stream is readable: "; +var_dump($stream->isReadable()); + +// ASSERT: Can read content +echo "Content matches: "; +$readContent = $stream->getContents(); +var_dump($readContent === $testContent); + +// ASSERT: Multiple getStream() calls return same cached stream +$stream2 = $uploadedFile->getStream(); +echo "Stream cached: "; +var_dump($stream === $stream2); + +// Cleanup +@unlink($tmpFile); +@rmdir($testDir); + +echo "getStream tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Implements StreamInterface: bool(true) +Stream is readable: bool(true) +Content matches: bool(true) +Stream cached: bool(true) +getStream tests passed diff --git a/tests/021_uploadedfile_psr17_create.phpt b/tests/021_uploadedfile_psr17_create.phpt new file mode 100644 index 0000000..14f7a3b --- /dev/null +++ b/tests/021_uploadedfile_psr17_create.phpt @@ -0,0 +1,59 @@ +--TEST-- +signalforge_http: UploadedFile PSR-17 create() factory method +--EXTENSIONS-- +signalforge_http +--FILE-- +getSize() === strlen($content)); + +echo "Error: "; +var_dump($uploadedFile->getError() === UPLOAD_ERR_OK); + +echo "Client filename: "; +var_dump($uploadedFile->getClientFilename() === 'factory-test.txt'); + +echo "Client media type: "; +var_dump($uploadedFile->getClientMediaType() === 'text/plain'); + +// ASSERT: Can get stream +$stream2 = $uploadedFile->getStream(); +echo "Stream accessible: "; +var_dump($stream2 !== null); + +echo "PSR-17 create() tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Implements UploadedFileInterface: bool(true) +Size: bool(true) +Error: bool(true) +Client filename: bool(true) +Client media type: bool(true) +Stream accessible: bool(true) +PSR-17 create() tests passed diff --git a/tests/022_uploadedfile_client_filename.phpt b/tests/022_uploadedfile_client_filename.phpt new file mode 100644 index 0000000..37a9028 --- /dev/null +++ b/tests/022_uploadedfile_client_filename.phpt @@ -0,0 +1,81 @@ +--TEST-- +signalforge_http: UploadedFile getClientFilename() returns correct values +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); + +// Test various filename scenarios +$testCases = [ + ['name' => 'simple.txt', 'expected' => 'simple.txt'], + ['name' => 'file with spaces.pdf', 'expected' => 'file with spaces.pdf'], + ['name' => 'UPPERCASE.TXT', 'expected' => 'UPPERCASE.TXT'], + ['name' => 'unicode-日本語.txt', 'expected' => 'unicode-日本語.txt'], + ['name' => 'dots.in.name.txt', 'expected' => 'dots.in.name.txt'], + ['name' => '.hidden', 'expected' => '.hidden'], + ['name' => '', 'expected' => null], // Empty string should return null or empty +]; + +foreach ($testCases as $i => $testCase) { + $tmpFile = $testDir . "/test_{$i}.tmp"; + file_put_contents($tmpFile, 'test'); + + $_FILES = [ + 'upload' => [ + 'name' => $testCase['name'], + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 4, + ], + ]; + + $request = Request::capture(); + $files = $request->getUploadedFiles(); + $uploadedFile = $files['upload']; + + $filename = $uploadedFile->getClientFilename(); + + echo "Test '{$testCase['name']}': "; + if ($testCase['expected'] === null) { + var_dump($filename === null || $filename === ''); + } else { + var_dump($filename === $testCase['expected']); + } + + @unlink($tmpFile); + unset($request, $files, $uploadedFile); + gc_collect_cycles(); +} + +@rmdir($testDir); +echo "getClientFilename tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Test 'simple.txt': bool(true) +Test 'file with spaces.pdf': bool(true) +Test 'UPPERCASE.TXT': bool(true) +Test 'unicode-日本語.txt': bool(true) +Test 'dots.in.name.txt': bool(true) +Test '.hidden': bool(true) +Test '': bool(true) +getClientFilename tests passed diff --git a/tests/023_uploadedfile_getsize.phpt b/tests/023_uploadedfile_getsize.phpt new file mode 100644 index 0000000..efc75ce --- /dev/null +++ b/tests/023_uploadedfile_getsize.phpt @@ -0,0 +1,74 @@ +--TEST-- +signalforge_http: UploadedFile getSize() returns correct file sizes +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); + +// Test various file sizes +$testCases = [ + 0, // Empty file + 1, // Single byte + 100, // Small file + 1024, // 1 KB + 1024 * 10, // 10 KB + 1024 * 100, // 100 KB +]; + +foreach ($testCases as $size) { + $tmpFile = $testDir . "/size_test.tmp"; + $content = str_repeat('x', $size); + file_put_contents($tmpFile, $content); + + $_FILES = [ + 'upload' => [ + 'name' => 'test.bin', + 'type' => 'application/octet-stream', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => $size, + ], + ]; + + $request = Request::capture(); + $files = $request->getUploadedFiles(); + $uploadedFile = $files['upload']; + + echo "Size {$size} bytes: "; + var_dump($uploadedFile->getSize() === $size); + + @unlink($tmpFile); + unset($request, $files, $uploadedFile); + gc_collect_cycles(); +} + +@rmdir($testDir); +echo "getSize tests passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Size 0 bytes: bool(true) +Size 1 bytes: bool(true) +Size 100 bytes: bool(true) +Size 1024 bytes: bool(true) +Size 10240 bytes: bool(true) +Size 102400 bytes: bool(true) +getSize tests passed diff --git a/tests/024_uploadedfile_moveto_nonexistent_dir.phpt b/tests/024_uploadedfile_moveto_nonexistent_dir.phpt new file mode 100644 index 0000000..d7679c5 --- /dev/null +++ b/tests/024_uploadedfile_moveto_nonexistent_dir.phpt @@ -0,0 +1,62 @@ +--TEST-- +signalforge_http: UploadedFile moveTo() rejects non-existent directory +--EXTENSIONS-- +signalforge_http +--FILE-- + 'POST', 'REQUEST_URI' => '/upload']; +$_GET = []; +$_POST = []; +$_COOKIE = []; + +// ARRANGE: Create test file +$testDir = sys_get_temp_dir() . '/signalforge_test_' . getmypid(); +@mkdir($testDir, 0755, true); +$tmpFile = $testDir . '/source.tmp'; +file_put_contents($tmpFile, 'test'); + +$_FILES = [ + 'upload' => [ + 'name' => 'test.txt', + 'type' => 'text/plain', + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 4, + ], +]; + +$request = Request::capture(); +$files = $request->getUploadedFiles(); +$uploadedFile = $files['upload']; + +// ACT & ASSERT: Non-existent directory should throw RuntimeException +echo "Non-existent directory rejected: "; +try { + $uploadedFile->moveTo('/nonexistent_directory_12345/file.txt'); + echo "FAIL - should have thrown\n"; +} catch (RuntimeException $e) { + var_dump(strpos($e->getMessage(), 'not exist') !== false || + strpos($e->getMessage(), 'not accessible') !== false); +} + +// Cleanup +@unlink($tmpFile); +@rmdir($testDir); + +echo "Non-existent directory test passed\n"; +?> +--CLEAN-- + +--EXPECT-- +Non-existent directory rejected: bool(true) +Non-existent directory test passed diff --git a/tests/047_uri_empty_components.phpt b/tests/047_uri_empty_components.phpt new file mode 100644 index 0000000..7fe2fee --- /dev/null +++ b/tests/047_uri_empty_components.phpt @@ -0,0 +1,63 @@ +--TEST-- +signalforge_http: Uri handles empty and missing components correctly +--EXTENSIONS-- +signalforge_http +--FILE-- +getScheme()); +echo "Path only - host: "; +var_dump($uri->getHost()); +echo "Path only - path: "; +var_dump($uri->getPath()); + +echo "---\n"; + +// Test URI with empty query +$uri = Uri::fromString('http://example.com/path?'); +echo "Empty query: "; +var_dump($uri->getQuery()); + +// Test URI with empty fragment +$uri = Uri::fromString('http://example.com/path#'); +echo "Empty fragment: "; +var_dump($uri->getFragment()); + +echo "---\n"; + +// Test scheme-relative URI (// prefix) +$uri = Uri::fromString('//example.com/path'); +echo "Scheme-relative - scheme: "; +var_dump($uri->getScheme()); +echo "Scheme-relative - host: "; +var_dump($uri->getHost()); +echo "Scheme-relative - path: "; +var_dump($uri->getPath()); + +echo "---\n"; + +// Test URI with only host +$uri = Uri::fromString('http://example.com'); +echo "Host only - path: "; +var_dump($uri->getPath()); + +echo "Empty/missing components tests passed\n"; +?> +--EXPECT-- +Path only - scheme: string(0) "" +Path only - host: string(0) "" +Path only - path: string(10) "/path/only" +--- +Empty query: string(0) "" +Empty fragment: string(0) "" +--- +Scheme-relative - scheme: string(0) "" +Scheme-relative - host: string(11) "example.com" +Scheme-relative - path: string(5) "/path" +--- +Host only - path: string(0) "" +Empty/missing components tests passed diff --git a/tests/048_uri_special_chars.phpt b/tests/048_uri_special_chars.phpt new file mode 100644 index 0000000..3db243d --- /dev/null +++ b/tests/048_uri_special_chars.phpt @@ -0,0 +1,48 @@ +--TEST-- +signalforge_http: Uri handles special characters in components +--EXTENSIONS-- +signalforge_http +--FILE-- +getPath()); + +// Test special chars in query +$uri = Uri::fromString('http://example.com/search?q=hello+world&filter=a%26b'); +echo "Query with special chars: "; +var_dump($uri->getQuery()); + +// Test multiple query parameters +$uri = Uri::fromString('http://example.com/api?name=John&age=30&city=New%20York'); +echo "Multiple params: "; +var_dump($uri->getQuery()); + +// Test userinfo with special chars +$uri = Uri::fromString('http://user%40domain:p%40ss@example.com/'); +echo "Encoded userinfo: "; +var_dump($uri->getUserInfo()); + +// Test fragment with special chars +$uri = Uri::fromString('http://example.com/doc#section%201'); +echo "Encoded fragment: "; +var_dump($uri->getFragment()); + +// Test unicode in path (should be percent-encoded) +$uri = Uri::fromString('http://example.com/caf%C3%A9'); +echo "Unicode path: "; +var_dump($uri->getPath()); + +echo "Special character tests passed\n"; +?> +--EXPECT-- +Encoded path: string(21) "/path%20with%20spaces" +Query with special chars: string(26) "q=hello+world&filter=a%26b" +Multiple params: string(32) "name=John&age=30&city=New%20York" +Encoded userinfo: string(20) "user%40domain:p%40ss" +Encoded fragment: string(11) "section%201" +Unicode path: string(10) "/caf%C3%A9" +Special character tests passed diff --git a/tests/049_uri_normalization.phpt b/tests/049_uri_normalization.phpt new file mode 100644 index 0000000..848c117 --- /dev/null +++ b/tests/049_uri_normalization.phpt @@ -0,0 +1,52 @@ +--TEST-- +signalforge_http: Uri normalizes components correctly +--EXTENSIONS-- +signalforge_http +--FILE-- +getScheme()); + +// Test host normalization (lowercase) +echo "Host normalized: "; +var_dump($uri->getHost()); + +// Test non-standard port preservation +$uri = Uri::fromString('http://example.com:8080/'); +echo "Non-standard port preserved: "; +var_dump($uri->getPort()); + +// Test standard ports are null +$uri = Uri::fromString('http://example.com:80/'); +echo "HTTP 80 returns null: "; +var_dump($uri->getPort()); + +$uri = Uri::fromString('https://example.com:443/'); +echo "HTTPS 443 returns null: "; +var_dump($uri->getPort()); + +// Test path normalization (no double slashes) +$uri = Uri::fromString('http://example.com//double//slashes'); +echo "Path preserved: "; +var_dump($uri->getPath()); + +// Test __toString reconstructs properly +$uri = Uri::fromString('https://user:pass@example.com:8443/path?query=value#fragment'); +echo "toString reconstruction: "; +var_dump((string)$uri); + +echo "Normalization tests passed\n"; +?> +--EXPECT-- +Scheme normalized: string(4) "http" +Host normalized: string(11) "example.com" +Non-standard port preserved: int(8080) +HTTP 80 returns null: NULL +HTTPS 443 returns null: NULL +Path preserved: string(17) "//double//slashes" +toString reconstruction: string(60) "https://user:pass@example.com:8443/path?query=value#fragment" +Normalization tests passed diff --git a/tests/050_uri_with_immutability.phpt b/tests/050_uri_with_immutability.phpt new file mode 100644 index 0000000..caf6ba1 --- /dev/null +++ b/tests/050_uri_with_immutability.phpt @@ -0,0 +1,77 @@ +--TEST-- +signalforge_http: Uri with* methods return new instances (immutability) +--EXTENSIONS-- +signalforge_http +--FILE-- +withScheme('https'); +echo "Original scheme unchanged: "; +var_dump($original->getScheme() === 'http'); +echo "Modified scheme: "; +var_dump($modified->getScheme() === 'https'); + +// Test withHost returns new instance +$modified = $original->withHost('other.com'); +echo "Original host unchanged: "; +var_dump($original->getHost() === 'example.com'); +echo "Modified host: "; +var_dump($modified->getHost() === 'other.com'); + +// Test withPort returns new instance +$modified = $original->withPort(8080); +echo "Original port unchanged: "; +var_dump($original->getPort() === null); +echo "Modified port: "; +var_dump($modified->getPort() === 8080); + +// Test withPath returns new instance +$modified = $original->withPath('/new/path'); +echo "Original path unchanged: "; +var_dump($original->getPath() === '/path'); +echo "Modified path: "; +var_dump($modified->getPath() === '/new/path'); + +// Test withQuery returns new instance +$modified = $original->withQuery('key=value'); +echo "Original query unchanged: "; +var_dump($original->getQuery() === ''); +echo "Modified query: "; +var_dump($modified->getQuery() === 'key=value'); + +// Test withFragment returns new instance +$modified = $original->withFragment('section'); +echo "Original fragment unchanged: "; +var_dump($original->getFragment() === ''); +echo "Modified fragment: "; +var_dump($modified->getFragment() === 'section'); + +// Test withUserInfo returns new instance +$modified = $original->withUserInfo('user', 'pass'); +echo "Original userInfo unchanged: "; +var_dump($original->getUserInfo() === ''); +echo "Modified userInfo: "; +var_dump($modified->getUserInfo() === 'user:pass'); + +echo "Immutability tests passed\n"; +?> +--EXPECT-- +Original scheme unchanged: bool(true) +Modified scheme: bool(true) +Original host unchanged: bool(true) +Modified host: bool(true) +Original port unchanged: bool(true) +Modified port: bool(true) +Original path unchanged: bool(true) +Modified path: bool(true) +Original query unchanged: bool(true) +Modified query: bool(true) +Original fragment unchanged: bool(true) +Modified fragment: bool(true) +Original userInfo unchanged: bool(true) +Modified userInfo: bool(true) +Immutability tests passed diff --git a/tests/051_uri_port_edge_cases.phpt b/tests/051_uri_port_edge_cases.phpt new file mode 100644 index 0000000..41c6350 --- /dev/null +++ b/tests/051_uri_port_edge_cases.phpt @@ -0,0 +1,59 @@ +--TEST-- +signalforge_http: Uri port edge cases +--EXTENSIONS-- +signalforge_http +--FILE-- +withPort(null); +echo "Port removed: "; +var_dump($modified->getPort()); + +// Test valid port range (1-65535) +$uri = Uri::fromString('http://example.com/'); + +// Test port 1 +$modified = $uri->withPort(1); +echo "Port 1: "; +var_dump($modified->getPort()); + +// Test port 65535 +$modified = $uri->withPort(65535); +echo "Port 65535: "; +var_dump($modified->getPort()); + +// Test common non-standard ports +$testPorts = [3000, 8000, 8080, 8443, 9000]; +foreach ($testPorts as $port) { + $modified = $uri->withPort($port); + echo "Port {$port}: "; + var_dump($modified->getPort() === $port); +} + +// Test that authority includes port when non-standard +$uri = Uri::fromString('http://example.com:8080/path'); +echo "Authority with port: "; +var_dump($uri->getAuthority()); + +// Test that authority excludes standard port +$uri = Uri::fromString('http://example.com:80/path'); +echo "Authority without standard port: "; +var_dump($uri->getAuthority()); + +echo "Port edge cases tests passed\n"; +?> +--EXPECT-- +Port removed: NULL +Port 1: int(1) +Port 65535: int(65535) +Port 3000: bool(true) +Port 8000: bool(true) +Port 8080: bool(true) +Port 8443: bool(true) +Port 9000: bool(true) +Authority with port: string(16) "example.com:8080" +Authority without standard port: string(11) "example.com" +Port edge cases tests passed