diff --git a/packages/php-wasm/compile/php/Dockerfile b/packages/php-wasm/compile/php/Dockerfile index 9ae9f9c24df..d0e634c560f 100644 --- a/packages/php-wasm/compile/php/Dockerfile +++ b/packages/php-wasm/compile/php/Dockerfile @@ -460,6 +460,15 @@ RUN /root/replace.sh 's/PHPAPI int php_exec(.+)$/PHPAPI extern int php_exec\1; i RUN /root/replace.sh 's/#define VCWD_POPEN.+/#define VCWD_POPEN(command, type) wasm_popen(command,type)/g' /root/php-src/Zend/zend_virtual_cwd.h RUN echo 'extern FILE *wasm_popen(const char *cmd, const char *mode);' >> /root/php-src/Zend/zend_virtual_cwd.h +# Patch mail.c for Emscripten compatibility: +# 1. Use VCWD_POPEN (routed through wasm_popen) instead of raw popen(), +# so that PHP's mail() function goes through our JS spawn handler. +# 2. Use wasm_pclose() instead of pclose() so we wait for the spawned +# process to finish and get a correct exit code. Libc's pclose() doesn't +# work with FILE handles created by wasm_popen (fdopen). +RUN sed -i '1i #include \nextern int wasm_pclose(FILE *fp);' /root/php-src/ext/standard/mail.c +RUN perl -pi.bak -e 's/\bpopen\s*\(\s*sendmail_cmd\s*,/VCWD_POPEN(sendmail_cmd,/g; s/\bpclose\s*\(\s*sendmail\s*\)/wasm_pclose(sendmail)/g' /root/php-src/ext/standard/mail.c + # Provide a custom implementation of the shutdown() function. RUN perl -pi.bak -e $'s/(\s+)shutdown\(/$1 wasm_shutdown(/g' /root/php-src/sapi/cli/php_cli_server.c RUN perl -pi.bak -e $'s/(\s+)closesocket\(/$1 wasm_close(/g' /root/php-src/sapi/cli/php_cli_server.c @@ -711,6 +720,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\ "php_init_config",\ "zend_register_constant",\ "zend_register_ini_entries_ex",\ +"php_mail",\ "php_module_startup",\ "wasm_sapi_module_startup",\ "__fseeko_unlocked",\ @@ -1664,6 +1674,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\ "wasm_php_exec",\ "wasm_php_stream_flush",\ "wasm_php_stream_read",\ +"wasm_pclose",\ "wasm_popen",\ "wasm_read",\ "wasm_sapi_handle_request",\ @@ -2028,6 +2039,7 @@ RUN export ASYNCIFY_IMPORTS=$'[\n\ "zif_passthru",\ "zif_phar_file_get_contents",\ "zif_phar_fopen",\ +"zif_mail",\ "zif_popen",\ "zif_post_message_to_js",\ "zif_preg_replace_callback",\ @@ -2234,7 +2246,7 @@ RUN set -euxo pipefail; \ source /root/emsdk/emsdk_env.sh; \ if [ "$WITH_JSPI" = "yes" ]; then \ # Both imports and exports are required for inter-module communication with wrapped methods, e.g., wasm_recv. - export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_fd_read,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv,wasm_connect,__syscall_fcntl64,js_flock,js_release_file_locks,js_waitpid -sJSPI_EXPORTS=php_wasm_init,wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_read,wasm_php_exec,run_cli,wasm_recv,wasm_connect,__wasm_call_ctors,__errno_location,__funcs_on_exit -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports "; \ + export ASYNCIFY_FLAGS=" -s ASYNCIFY=2 -sSUPPORT_LONGJMP=wasm -fwasm-exceptions -sJSPI_IMPORTS=js_open_process,js_fd_read,js_waitpid,js_process_status,js_create_input_device,wasm_setsockopt,wasm_shutdown,wasm_close,wasm_recv,wasm_connect,__syscall_fcntl64,js_flock,js_release_file_locks,js_waitpid -sJSPI_EXPORTS=php_wasm_init,wasm_sleep,wasm_read,emscripten_sleep,wasm_sapi_handle_request,wasm_sapi_request_shutdown,wasm_poll_socket,wrap_select,__wrap_select,select,php_pollfd_for,fflush,wasm_popen,wasm_pclose,wasm_read,wasm_php_exec,run_cli,wasm_recv,wasm_connect,__wasm_call_ctors,__errno_location,__funcs_on_exit -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports "; \ echo '#define PLAYGROUND_JSPI 1' > /root/php_wasm_asyncify.h; \ else \ export ASYNCIFY_FLAGS=" -s ASYNCIFY=1 -s ASYNCIFY_IGNORE_INDIRECT=1 -s EXPORTED_RUNTIME_METHODS=HEAPU32,HEAPU8,ccall,PROXYFS,wasmExports,UTF8ToString,lengthBytesUTF8,stringToUTF8 $(cat /root/.emcc-php-asyncify-flags) "; \ diff --git a/packages/php-wasm/compile/php/php_wasm.c b/packages/php-wasm/compile/php/php_wasm.c index 4dcdf90b33a..829e628f1a5 100644 --- a/packages/php-wasm/compile/php/php_wasm.c +++ b/packages/php-wasm/compile/php/php_wasm.c @@ -430,6 +430,10 @@ extern int __wasi_syscall_ret(__wasi_errno_t code); // Exit code of the last exited child process call. int wasm_pclose_ret = -1; +// PID of the last process spawned by wasm_popen("w"). +// Used by wasm_pclose to wait for the process to finish. +static int wasm_popen_last_pid = -1; + /** * Passes a message to the JavaScript module and writes the response * data, if any, to the response_buffer pointer. @@ -515,7 +519,7 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode) return 0; } - fp = fdopen(stdin_pipe[1], "w"); // or "w", depending on direction + fp = fdopen(stdin_pipe[1], "w"); if (!fp) { php_error_docref(NULL, E_WARNING, "unable to create pipe %s", strerror(errno)); errno = EINVAL; @@ -544,8 +548,7 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode) descv[1] = stdout; descv[2] = stderr; - // the wasm way {{{ - js_open_process( + wasm_popen_last_pid = js_open_process( cmd, NULL, 0, @@ -556,7 +559,6 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode) 0, 0 ); - // }}} efree(stdin); efree(stdout); @@ -574,6 +576,32 @@ EMSCRIPTEN_KEEPALIVE FILE *wasm_popen(const char *cmd, const char *mode) return fp; } +/** + * Close a FILE* created by wasm_popen and wait for the spawned process + * to exit. Returns the process exit code, or -1 on error. + * + * @TODO wasm_popen_last_pid and wasm_pclose_ret are single globals, + * so concurrent writable popen() calls will clobber each other's + * PID and exit code. Safe today because mail() is the only caller + * and it does a strict open-write-close sequence, but a proper fix + * would stash both in a table keyed by fd. + */ +extern int js_waitpid(int pid, int *exitcode); + +EMSCRIPTEN_KEEPALIVE int wasm_pclose(FILE *fp) +{ + int pid = wasm_popen_last_pid; + fclose(fp); + if (pid < 0) { + return -1; + } + int wstatus = 0; + js_waitpid(pid, &wstatus); + wasm_pclose_ret = wstatus; + FG(pclose_ret) = wstatus; + return wstatus; +} + /** * Ship php_exec, the function powering the following PHP * functions: diff --git a/packages/php-wasm/node-builds/8-4/asyncify/8_4_19/php_8_4.wasm b/packages/php-wasm/node-builds/8-4/asyncify/8_4_20/php_8_4.wasm similarity index 83% rename from packages/php-wasm/node-builds/8-4/asyncify/8_4_19/php_8_4.wasm rename to packages/php-wasm/node-builds/8-4/asyncify/8_4_20/php_8_4.wasm index cbae2f0aa17..27a53d41497 100755 Binary files a/packages/php-wasm/node-builds/8-4/asyncify/8_4_19/php_8_4.wasm and b/packages/php-wasm/node-builds/8-4/asyncify/8_4_20/php_8_4.wasm differ diff --git a/packages/php-wasm/node-builds/8-4/asyncify/php_8_4.js b/packages/php-wasm/node-builds/8-4/asyncify/php_8_4.js index 2bd0957b362..f201e4e4fd0 100644 --- a/packages/php-wasm/node-builds/8-4/asyncify/php_8_4.js +++ b/packages/php-wasm/node-builds/8-4/asyncify/php_8_4.js @@ -13,10 +13,10 @@ const currentDirPath = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -const dependencyFilename = path.join(currentDirPath, '8_4_19', 'php_8_4.wasm'); +const dependencyFilename = path.join(currentDirPath, '8_4_20', 'php_8_4.wasm'); export { dependencyFilename }; -export const dependenciesTotalSize = 25379364; -const phpVersionString = '8.4.19'; +export const dependenciesTotalSize = 25390764; +const phpVersionString = '8.4.20'; export function init(RuntimeName, PHPLoader) { // The rest of the code comes from the built php.js file and esm-suffix.js // include: shell.js @@ -9553,7 +9553,7 @@ function __asyncjs__js_module_onMessage(data, response_buffer) { __asyncjs__js_module_onMessage.sig = "iii"; // Imports from the Wasm binary. -var _php_date_get_date_ce, _php_date_get_interface_ce, _php_date_get_timezone_ce, _get_timezone_info, _php_combined_lcg, _php_setcookie, _php_escape_html_entities, _php_info_print_table_header, _php_info_print_table_row, _php_info_print_table_start, _php_info_print_table_end, _php_info_print_table_colspan_header, _php_str_to_str, _php_addcslashes_str, _php_addcslashes, _php_printf, _php_get_module_initialized, _php_log_err_with_severity, _php_error_docref, _php_socket_strerror, _php_output_write, _display_ini_entries, _sapi_header_op, _ap_php_slprintf, _ap_php_snprintf, _ap_php_vsnprintf, __php_stream_free, __php_stream_eof, __php_stream_get_line, __php_stream_open_wrapper_ex, __emalloc_24, __emalloc_32, __emalloc_40, __emalloc_48, __emalloc_56, __emalloc_128, __emalloc_160, __emalloc_320, __emalloc_1280, __efree_56, __emalloc, __efree, __erealloc, __safe_emalloc, ___zend_malloc, __safe_erealloc, ___zend_realloc, __ecalloc, __estrdup, __estrndup, _zend_set_memory_limit, _zend_memory_usage, _zend_memory_peak_usage, _zend_get_parameters_array_ex, _zend_wrong_param_count, _zend_zval_value_name, _zend_wrong_parameters_none_error, _zend_wrong_parameters_count_error, _zend_wrong_parameter_error, _zend_argument_type_error, _zend_argument_value_error, _zend_argument_error, _zend_argument_must_not_be_empty_error, _zend_parse_arg_bool_slow, _zend_flf_parse_arg_bool_slow, _zend_parse_arg_long_slow, _zend_flf_parse_arg_long_slow, _zend_parse_arg_str_slow, _zend_flf_parse_arg_str_slow, _zend_parse_arg_str_or_long_slow, _zend_release_fcall_info_cache, _zend_parse_parameters, _zend_parse_method_parameters, _object_properties_init, _object_init_ex, _add_assoc_long_ex, _add_assoc_null_ex, _add_assoc_bool_ex, _add_assoc_double_ex, _add_assoc_str_ex, _add_assoc_string_ex, _add_assoc_stringl_ex, _add_assoc_zval_ex, _add_index_long, _add_index_null, _add_index_string, _add_index_stringl, _add_next_index_long, _add_next_index_str, _add_next_index_string, _add_next_index_stringl, _zend_startup_module, _zend_register_internal_class_with_flags, _zend_class_implements, _zend_fcall_info_init, _zend_get_module_version, _zend_declare_typed_property, _zend_try_assign_typed_ref_bool, _zend_try_assign_typed_ref_long, _zend_try_assign_typed_ref_str, _zend_try_assign_typed_ref_arr, _zend_declare_typed_class_constant, _zend_update_property, _zend_read_property_ex, _zend_read_property, _zend_replace_error_handling, _zend_restore_error_handling, _zend_get_parameter_attribute_str, _zend_add_attribute, _zend_get_closure_method_def, _zend_type_to_string, _zend_unmangle_property_name_ex, _zend_is_auto_global_str, _zend_get_compiled_variable_name, _zend_register_long_constant, _zend_register_string_constant, _zend_get_constant_str, _zend_get_exception_base, _zend_is_unwind_exit, _zend_is_graceful_exit, _zend_clear_exception, _zend_throw_exception, _zend_throw_exception_ex, _zend_throw_error_exception, _get_active_class_name, _get_active_function_name, _zend_get_executed_filename, _zend_get_executed_filename_ex, _zend_get_executed_lineno, __call_user_function_impl, _zend_call_function, _zend_call_known_function, _zend_call_known_instance_method_with_2_params, _zend_eval_string, _zend_set_timeout, _zend_unset_timeout, _zend_fetch_class, _zend_rebuild_symbol_table, _zend_get_zval_ptr, _zend_set_user_opcode_handler, _zend_get_user_opcode_handler, _zend_get_resource_handle, _gc_enabled, _gc_possible_root, _zend_gc_get_status, _zend_get_gc_buffer_create, _zend_get_gc_buffer_grow, _zend_hash_str_find, __zend_hash_init, __zend_new_array_0, __zend_new_array, _zend_array_dup, _zend_hash_update, _zend_hash_str_update, _zend_hash_next_index_insert, _zend_hash_index_update, _zend_hash_destroy, _zend_array_destroy, _zend_hash_apply_with_arguments, _zend_hash_copy, _zend_hash_find, _zend_hash_index_find, _zend_hash_sort_ex, __zend_handle_numeric_str_ex, _zend_html_puts, _zend_register_ini_entries_ex, _zend_unregister_ini_entries_ex, _zend_alter_ini_entry, _zend_ini_string_ex, _zend_ini_string, _zend_ini_boolean_displayer_cb, _OnUpdateBool, _OnUpdateLong, _OnUpdateString, _OnUpdateStringUnempty, _zend_call_method, _zend_create_internal_iterator_zval, _zend_iterator_init, _zend_rsrc_list_get_rsrc_type, _zend_std_get_properties, _zend_get_properties_no_lazy_init, _zend_get_property_info, _zend_class_init_statics, _zend_std_compare_objects, _zend_get_properties_for, _zend_objects_store_mark_destructed, _zend_objects_store_del, _zend_object_std_init, _zend_object_std_dtor, _zend_objects_clone_members, _zend_observer_fcall_register, _zend_observer_fiber_switch_register, __is_numeric_string_ex, _zval_try_get_long, _convert_to_long, _zval_get_long_func, _convert_to_double, __convert_to_string, __try_convert_to_string, _zval_get_double_func, _zval_get_string_func, _zend_binary_strcasecmp, _numeric_compare_function, _compare_function, _instanceof_function_slow, _zend_str_tolower, _zend_memnstr_ex, _smart_str_erealloc, __smart_string_alloc, _zend_sort, _zend_strtod, _rc_dtor_func, _zval_ptr_dtor, _zval_add_ref, _virtual_file_ex, _tsrm_realpath, _zend_vspprintf, _zend_spprintf, _zend_strpprintf, __zend_bailout, _zend_error, _zend_throw_error, _zend_illegal_container_offset, _zend_argument_count_error, _zend_value_error, _strlen, _munmap, _fiprintf, _abort, _free, _memcmp, _malloc, _snprintf, _strchr, _clock_gettime, _dlopen, _dlsym, _dlclose, _strcmp, _getenv, ___wasm_setjmp, ___wasm_setjmp_test, _emscripten_longjmp, _atoi, ___errno_location, _strtoull, _strrchr, _realloc, _strcasecmp, _memchr, _fwrite, _strncmp, _isxdigit, _tolower, _strtok_r, _strncasecmp, _fileno, _isatty, _fread, _fclose, _strtoul, _strstr, _strpbrk, _strdup, _write, _close, _stat, _gettimeofday, _iprintf, _puts, _putchar, _fopen, _getcwd, _open, _strncpy, _siprintf, _localtime_r, _strtol, _pow, _strtod, _strftime, _round, _sin, _cos, _atan2, _acos, _tan, _asin, _atan, _log, _log2, _fmod, _setlocale, _strerror, _wasm_popen, _wasm_php_exec, _socket, _freeaddrinfo, _fcntl, _connect, _php_pollfd_for, _htons, _ntohs, _getpeername, _htonl, _strcpy, _strcat, _tzset, _wasm_sleep, _fputs, _isdigit, _fflush, _calloc, _expf, ___small_fprintf, _qsort, _vfprintf, _mmap, _flock, _fgets, _initgroups, _atol, _wasm_read, _feof, _strncat, ___ctype_get_mb_cur_max, ___wrap_usleep, _poll, ___wrap_select, _wasm_set_sapi_name, _wasm_set_phpini_path, _wasm_add_cli_arg, _run_cli, _wasm_add_SERVER_entry, _wasm_add_ENV_entry, _wasm_set_query_string, _wasm_set_path_translated, _wasm_set_skip_shebang, _wasm_set_request_uri, _wasm_set_request_method, _wasm_set_request_host, _wasm_set_content_type, _wasm_set_request_body, _wasm_set_content_length, _wasm_set_cookies, _wasm_set_request_port, _wasm_sapi_request_shutdown, _wasm_sapi_handle_request, _php_wasm_init, _wasm_free, _wasm_get_end_offset, ___wrap_getpid, _wasm_trace, _rewind, _modf, _gmtime, ___extenddftf2, ___letf2, ___floatunditf, _div, ___funcs_on_exit, ___cxa_atexit, ___dl_seterr, __emscripten_find_dylib, _freopen, _mbstowcs, _emscripten_builtin_memalign, __emscripten_timeout, _strtok, _tanhf, _wcstombs, _emscripten_get_sbrk_ptr, _setThrew, __emscripten_tempret_set, __emscripten_tempret_get, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, __ZNSt3__211__call_onceERVmPvPFvS2_E, __ZNSt3__218condition_variable10notify_allEv, __ZNSt3__25mutex4lockEv, __ZNSt3__25mutex6unlockEv, ___cxa_bad_typeid, ___cxa_allocate_exception, ___cxa_pure_virtual, ___dynamic_cast, ___cxa_can_catch, __ZNSt20bad_array_new_lengthD1Ev, __ZNSt12length_errorD1Ev, dynCall_iiii, dynCall_ii, dynCall_vi, dynCall_vii, dynCall_viiiii, dynCall_iii, dynCall_iiiii, dynCall_iiiiii, dynCall_viii, dynCall_iij, dynCall_v, dynCall_i, dynCall_viiii, dynCall_iiiiiii, dynCall_iijii, dynCall_jii, dynCall_jiii, dynCall_jijj, dynCall_viiiiiiii, dynCall_jiiiii, dynCall_jiiii, dynCall_iiiiiiiiii, dynCall_vjiii, dynCall_iiji, dynCall_iidddd, dynCall_iijjjj, dynCall_vijii, dynCall_iijji, dynCall_ji, dynCall_iiiiiiiiiiij, dynCall_iiiiiiiiiii, dynCall_iiiij, dynCall_iiiiiiii, dynCall_iiiiiiiiiiii, dynCall_iiiiiiiii, dynCall_viiiiiii, dynCall_vji, dynCall_vijj, dynCall_iiij, dynCall_iijiji, dynCall_jiji, dynCall_viiiiii, dynCall_viiij, dynCall_viiiiiiiii, dynCall_vidi, dynCall_viijii, dynCall_viidii, dynCall_jiiji, dynCall_jj, dynCall_jiiiji, dynCall_jiij, dynCall_iiiji, dynCall_ij, dynCall_iiiiiij, dynCall_iiid, dynCall_dii, dynCall_vid, dynCall_vij, dynCall_di, dynCall_iiiiijii, dynCall_j, dynCall_iiiiji, dynCall_iiiijii, dynCall_viiji, dynCall_iiiijji, dynCall_dd, dynCall_ddd, dynCall_iiijii, dynCall_diiii, dynCall_diiiiiiii, dynCall_fi, dynCall_fii, dynCall_viiiiiiiiiii, dynCall_viiiiiiiiiiiii, dynCall_viiiiiiiiiiiiiii, dynCall_iiiijj, dynCall_jiiiiiiiii, dynCall_jiiiiii, dynCall_jiiiiiiii, dynCall_ddi, dynCall_iiijj, dynCall_id, dynCall_iifi, dynCall_viid, dynCall_viidddddddd, dynCall_iidiiii, _asyncify_start_unwind, _asyncify_stop_unwind, _asyncify_start_rewind, _asyncify_stop_rewind, memory, ___stack_pointer, __indirect_function_table, wasmTable, wasmMemory; +var _php_date_get_date_ce, _php_date_get_interface_ce, _php_date_get_timezone_ce, _get_timezone_info, _php_combined_lcg, _php_setcookie, _php_escape_html_entities, _php_info_print_table_header, _php_info_print_table_row, _php_info_print_table_start, _php_info_print_table_end, _php_info_print_table_colspan_header, _php_str_to_str, _php_addcslashes_str, _php_addcslashes, _php_printf, _php_get_module_initialized, _php_log_err_with_severity, _php_error_docref, _php_socket_strerror, _php_output_write, _display_ini_entries, _sapi_header_op, _ap_php_slprintf, _ap_php_snprintf, _ap_php_vsnprintf, __php_stream_free, __php_stream_eof, __php_stream_get_line, __php_stream_open_wrapper_ex, __emalloc_24, __emalloc_32, __emalloc_40, __emalloc_48, __emalloc_56, __emalloc_128, __emalloc_160, __emalloc_320, __emalloc_1280, __efree_56, __emalloc, __efree, __erealloc, __safe_emalloc, ___zend_malloc, __safe_erealloc, ___zend_realloc, __ecalloc, __estrdup, __estrndup, _zend_set_memory_limit, _zend_memory_usage, _zend_memory_peak_usage, _zend_get_parameters_array_ex, _zend_wrong_param_count, _zend_zval_value_name, _zend_wrong_parameters_none_error, _zend_wrong_parameters_count_error, _zend_wrong_parameter_error, _zend_argument_type_error, _zend_argument_value_error, _zend_argument_error, _zend_argument_must_not_be_empty_error, _zend_parse_arg_bool_slow, _zend_flf_parse_arg_bool_slow, _zend_parse_arg_long_slow, _zend_flf_parse_arg_long_slow, _zend_parse_arg_str_slow, _zend_flf_parse_arg_str_slow, _zend_parse_arg_str_or_long_slow, _zend_release_fcall_info_cache, _zend_parse_parameters, _zend_parse_method_parameters, _object_properties_init, _object_init_ex, _add_assoc_long_ex, _add_assoc_null_ex, _add_assoc_bool_ex, _add_assoc_double_ex, _add_assoc_str_ex, _add_assoc_string_ex, _add_assoc_stringl_ex, _add_assoc_zval_ex, _add_index_long, _add_index_null, _add_index_string, _add_index_stringl, _add_next_index_long, _add_next_index_str, _add_next_index_string, _add_next_index_stringl, _zend_startup_module, _zend_register_internal_class_with_flags, _zend_class_implements, _zend_fcall_info_init, _zend_get_module_version, _zend_declare_typed_property, _zend_try_assign_typed_ref_bool, _zend_try_assign_typed_ref_long, _zend_try_assign_typed_ref_str, _zend_try_assign_typed_ref_arr, _zend_declare_typed_class_constant, _zend_update_property, _zend_read_property_ex, _zend_read_property, _zend_replace_error_handling, _zend_restore_error_handling, _zend_get_parameter_attribute_str, _zend_add_attribute, _zend_get_closure_method_def, _zend_type_to_string, _zend_unmangle_property_name_ex, _zend_is_auto_global_str, _zend_get_compiled_variable_name, _zend_register_long_constant, _zend_register_string_constant, _zend_get_constant_str, _zend_get_exception_base, _zend_is_unwind_exit, _zend_is_graceful_exit, _zend_clear_exception, _zend_throw_exception, _zend_throw_exception_ex, _zend_throw_error_exception, _get_active_class_name, _get_active_function_name, _zend_get_executed_filename, _zend_get_executed_filename_ex, _zend_get_executed_lineno, __call_user_function_impl, _zend_call_function, _zend_call_known_function, _zend_call_known_instance_method_with_2_params, _zend_eval_string, _zend_set_timeout, _zend_unset_timeout, _zend_fetch_class, _zend_rebuild_symbol_table, _zend_get_zval_ptr, _zend_set_user_opcode_handler, _zend_get_user_opcode_handler, _zend_get_resource_handle, _gc_enabled, _gc_possible_root, _zend_gc_get_status, _zend_get_gc_buffer_create, _zend_get_gc_buffer_grow, _zend_hash_str_find, __zend_hash_init, __zend_new_array_0, __zend_new_array, _zend_array_dup, _zend_hash_update, _zend_hash_str_update, _zend_hash_next_index_insert, _zend_hash_index_update, _zend_hash_destroy, _zend_array_destroy, _zend_hash_apply_with_arguments, _zend_hash_copy, _zend_hash_find, _zend_hash_index_find, _zend_hash_sort_ex, __zend_handle_numeric_str_ex, _zend_html_puts, _zend_register_ini_entries_ex, _zend_unregister_ini_entries_ex, _zend_alter_ini_entry, _zend_ini_string_ex, _zend_ini_string, _zend_ini_boolean_displayer_cb, _OnUpdateBool, _OnUpdateLong, _OnUpdateString, _OnUpdateStringUnempty, _zend_call_method, _zend_create_internal_iterator_zval, _zend_iterator_init, _zend_rsrc_list_get_rsrc_type, _zend_std_get_properties, _zend_get_properties_no_lazy_init, _zend_get_property_info, _zend_class_init_statics, _zend_std_compare_objects, _zend_get_properties_for, _zend_objects_store_mark_destructed, _zend_objects_store_del, _zend_object_std_init, _zend_object_std_dtor, _zend_objects_clone_members, _zend_observer_fcall_register, _zend_observer_fiber_switch_register, __is_numeric_string_ex, _zval_try_get_long, _convert_to_long, _zval_get_long_func, _convert_to_double, __convert_to_string, __try_convert_to_string, _zval_get_double_func, _zval_get_string_func, _zend_binary_strcasecmp, _numeric_compare_function, _compare_function, _instanceof_function_slow, _zend_str_tolower, _zend_memnstr_ex, _smart_str_erealloc, __smart_string_alloc, _zend_sort, _zend_strtod, _rc_dtor_func, _zval_ptr_dtor, _zval_add_ref, _virtual_file_ex, _tsrm_realpath, _zend_vspprintf, _zend_spprintf, _zend_strpprintf, __zend_bailout, _zend_error, _zend_throw_error, _zend_illegal_container_offset, _zend_argument_count_error, _zend_value_error, _strlen, _munmap, _fiprintf, _abort, _free, _memcmp, _malloc, _snprintf, _strchr, _clock_gettime, _dlopen, _dlsym, _dlclose, _strcmp, _getenv, ___wasm_setjmp, ___wasm_setjmp_test, _emscripten_longjmp, _atoi, ___errno_location, _strtoull, _strrchr, _realloc, _strcasecmp, _memchr, _fwrite, _strncmp, _isxdigit, _tolower, _strtok_r, _strncasecmp, _fileno, _isatty, _fread, _fclose, _strtoul, _strstr, _strpbrk, _strdup, _write, _close, _stat, _gettimeofday, _iprintf, _puts, _putchar, _fopen, _getcwd, _open, _strncpy, _siprintf, _localtime_r, _strtol, _pow, _strtod, _strftime, _round, _sin, _cos, _atan2, _acos, _tan, _asin, _atan, _log, _log2, _fmod, _setlocale, _strerror, _wasm_popen, _wasm_php_exec, _socket, _freeaddrinfo, _fcntl, _connect, _php_pollfd_for, _htons, _ntohs, _getpeername, _htonl, _strcpy, _strcat, _wasm_pclose, _tzset, _wasm_sleep, _fputs, _isdigit, _fflush, _calloc, _expf, ___small_fprintf, _qsort, _vfprintf, _mmap, _flock, _fgets, _initgroups, _atol, _wasm_read, _feof, _strncat, ___ctype_get_mb_cur_max, ___wrap_usleep, _poll, ___wrap_select, _wasm_set_sapi_name, _wasm_set_phpini_path, _wasm_add_cli_arg, _run_cli, _wasm_add_SERVER_entry, _wasm_add_ENV_entry, _wasm_set_query_string, _wasm_set_path_translated, _wasm_set_skip_shebang, _wasm_set_request_uri, _wasm_set_request_method, _wasm_set_request_host, _wasm_set_content_type, _wasm_set_request_body, _wasm_set_content_length, _wasm_set_cookies, _wasm_set_request_port, _wasm_sapi_request_shutdown, _wasm_sapi_handle_request, _php_wasm_init, _wasm_free, _wasm_get_end_offset, ___wrap_getpid, _wasm_trace, _rewind, _modf, _gmtime, ___extenddftf2, ___letf2, ___floatunditf, _div, ___funcs_on_exit, ___cxa_atexit, ___dl_seterr, __emscripten_find_dylib, _freopen, _mbstowcs, _emscripten_builtin_memalign, __emscripten_timeout, _strtok, _tanhf, _wcstombs, _emscripten_get_sbrk_ptr, _setThrew, __emscripten_tempret_set, __emscripten_tempret_get, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, __ZNSt3__211__call_onceERVmPvPFvS2_E, __ZNSt3__218condition_variable10notify_allEv, __ZNSt3__25mutex4lockEv, __ZNSt3__25mutex6unlockEv, ___cxa_bad_typeid, ___cxa_allocate_exception, ___cxa_pure_virtual, ___dynamic_cast, ___cxa_can_catch, __ZNSt20bad_array_new_lengthD1Ev, __ZNSt12length_errorD1Ev, dynCall_iiii, dynCall_ii, dynCall_vi, dynCall_vii, dynCall_viiiii, dynCall_iii, dynCall_iiiii, dynCall_iiiiii, dynCall_viii, dynCall_iij, dynCall_v, dynCall_i, dynCall_viiii, dynCall_iiiiiii, dynCall_iijii, dynCall_jii, dynCall_jiii, dynCall_jijj, dynCall_viiiiiiii, dynCall_jiiiii, dynCall_jiiii, dynCall_iiiiiiiiii, dynCall_vjiii, dynCall_iiji, dynCall_iidddd, dynCall_iijjjj, dynCall_vijii, dynCall_iijji, dynCall_ji, dynCall_iiiiiiiiiiij, dynCall_iiiiiiiiiii, dynCall_iiiij, dynCall_iiiiiiii, dynCall_iiiiiiiiiiii, dynCall_iiiiiiiii, dynCall_viiiiiii, dynCall_vji, dynCall_vijj, dynCall_iiij, dynCall_iijiji, dynCall_jiji, dynCall_viiiiii, dynCall_viiij, dynCall_viiiiiiiii, dynCall_vidi, dynCall_viijii, dynCall_viidii, dynCall_jiiji, dynCall_jj, dynCall_jiiiji, dynCall_jiij, dynCall_iiiji, dynCall_ij, dynCall_iiiiiij, dynCall_iiid, dynCall_dii, dynCall_vid, dynCall_vij, dynCall_di, dynCall_iiiiijii, dynCall_j, dynCall_iiiiji, dynCall_iiiijii, dynCall_viiji, dynCall_iiiijji, dynCall_dd, dynCall_ddd, dynCall_iiijii, dynCall_diiii, dynCall_diiiiiiii, dynCall_fi, dynCall_fii, dynCall_viiiiiiiiiii, dynCall_viiiiiiiiiiiii, dynCall_viiiiiiiiiiiiiii, dynCall_iiiijj, dynCall_jiiiiiiiii, dynCall_jiiiiii, dynCall_jiiiiiiii, dynCall_ddi, dynCall_iiijj, dynCall_id, dynCall_iifi, dynCall_viid, dynCall_viidddddddd, dynCall_iidiiii, _asyncify_start_unwind, _asyncify_stop_unwind, _asyncify_start_rewind, _asyncify_stop_rewind, memory, ___stack_pointer, __indirect_function_table, wasmTable, wasmMemory; function assignWasmExports(wasmExports) { _php_date_get_date_ce = Module["_php_date_get_date_ce"] = wasmExports["php_date_get_date_ce"]; @@ -9863,6 +9863,7 @@ function assignWasmExports(wasmExports) { _htonl = wasmExports["htonl"]; _strcpy = Module["_strcpy"] = wasmExports["strcpy"]; _strcat = Module["_strcat"] = wasmExports["strcat"]; + _wasm_pclose = Module["_wasm_pclose"] = wasmExports["wasm_pclose"]; _tzset = Module["_tzset"] = wasmExports["tzset"]; _wasm_sleep = Module["_wasm_sleep"] = wasmExports["wasm_sleep"]; _fputs = Module["_fputs"] = wasmExports["fputs"]; @@ -10040,99 +10041,99 @@ function assignWasmExports(wasmExports) { __indirect_function_table = wasmTable = wasmExports["__indirect_function_table"]; } -var _core_globals = Module["_core_globals"] = 15558704; +var _core_globals = Module["_core_globals"] = 15559728; -var _php_ini_opened_path = Module["_php_ini_opened_path"] = 15447584; +var _php_ini_opened_path = Module["_php_ini_opened_path"] = 15448608; -var _php_ini_scanned_path = Module["_php_ini_scanned_path"] = 15447588; +var _php_ini_scanned_path = Module["_php_ini_scanned_path"] = 15448612; -var _php_ini_scanned_files = Module["_php_ini_scanned_files"] = 15447592; +var _php_ini_scanned_files = Module["_php_ini_scanned_files"] = 15448616; -var _sapi_module = Module["_sapi_module"] = 15442760; +var _sapi_module = Module["_sapi_module"] = 15443784; -var _sapi_globals = Module["_sapi_globals"] = 15442904; +var _sapi_globals = Module["_sapi_globals"] = 15443928; -var _module_registry = Module["_module_registry"] = 15560664; +var _module_registry = Module["_module_registry"] = 15561688; -var _zend_ce_closure = Module["_zend_ce_closure"] = 15554316; +var _zend_ce_closure = Module["_zend_ce_closure"] = 15555340; -var _compiler_globals = Module["_compiler_globals"] = 15562216; +var _compiler_globals = Module["_compiler_globals"] = 15563240; -var _executor_globals = Module["_executor_globals"] = 15562632; +var _executor_globals = Module["_executor_globals"] = 15563656; -var _zend_compile_file = Module["_zend_compile_file"] = 15564024; +var _zend_compile_file = Module["_zend_compile_file"] = 15565048; -var _zend_ce_exception = Module["_zend_ce_exception"] = 15559788; +var _zend_ce_exception = Module["_zend_ce_exception"] = 15560812; -var _zend_ce_error = Module["_zend_ce_error"] = 15559904; +var _zend_ce_error = Module["_zend_ce_error"] = 15560928; -var _zend_throw_exception_hook = Module["_zend_throw_exception_hook"] = 15559780; +var _zend_throw_exception_hook = Module["_zend_throw_exception_hook"] = 15560804; -var _zend_ce_throwable = Module["_zend_ce_throwable"] = 15559784; +var _zend_ce_throwable = Module["_zend_ce_throwable"] = 15560808; -var _zend_execute_ex = Module["_zend_execute_ex"] = 15561044; +var _zend_execute_ex = Module["_zend_execute_ex"] = 15562068; -var _zend_execute_internal = Module["_zend_execute_internal"] = 15561048; +var _zend_execute_internal = Module["_zend_execute_internal"] = 15562072; -var _zend_pass_function = Module["_zend_pass_function"] = 14924880; +var _zend_pass_function = Module["_zend_pass_function"] = 14925904; -var _zend_extensions = Module["_zend_extensions"] = 15559532; +var _zend_extensions = Module["_zend_extensions"] = 15560556; -var _gc_collect_cycles = Module["_gc_collect_cycles"] = 15561040; +var _gc_collect_cycles = Module["_gc_collect_cycles"] = 15562064; -var _zend_empty_array = Module["_zend_empty_array"] = 14939792; +var _zend_empty_array = Module["_zend_empty_array"] = 14940816; -var _zend_ce_aggregate = Module["_zend_ce_aggregate"] = 15442128; +var _zend_ce_aggregate = Module["_zend_ce_aggregate"] = 15443152; -var _zend_ce_iterator = Module["_zend_ce_iterator"] = 15442132; +var _zend_ce_iterator = Module["_zend_ce_iterator"] = 15443156; -var _zend_ce_countable = Module["_zend_ce_countable"] = 15442144; +var _zend_ce_countable = Module["_zend_ce_countable"] = 15443168; -var _std_object_handlers = Module["_std_object_handlers"] = 14939280; +var _std_object_handlers = Module["_std_object_handlers"] = 14940304; -var _zend_empty_string = Module["_zend_empty_string"] = 15440640; +var _zend_empty_string = Module["_zend_empty_string"] = 15441664; -var _zend_known_strings = Module["_zend_known_strings"] = 15440644; +var _zend_known_strings = Module["_zend_known_strings"] = 15441668; -var _zend_string_init_interned = Module["_zend_string_init_interned"] = 15440708; +var _zend_string_init_interned = Module["_zend_string_init_interned"] = 15441732; -var _zend_write = Module["_zend_write"] = 15562132; +var _zend_write = Module["_zend_write"] = 15563156; -var _zend_error_cb = Module["_zend_error_cb"] = 15562136; +var _zend_error_cb = Module["_zend_error_cb"] = 15563160; -var _zend_post_startup_cb = Module["_zend_post_startup_cb"] = 15562104; +var _zend_post_startup_cb = Module["_zend_post_startup_cb"] = 15563128; var ___memory_base = Module["___memory_base"] = 0; var ___table_base = Module["___table_base"] = 1; -var _stderr = Module["_stderr"] = 15433824; +var _stderr = Module["_stderr"] = 15434848; -var ___THREW__ = Module["___THREW__"] = 15912388; +var ___THREW__ = Module["___THREW__"] = 15913412; -var ___threwValue = Module["___threwValue"] = 15912392; +var ___threwValue = Module["___threwValue"] = 15913416; -var _stdout = Module["_stdout"] = 15434128; +var _stdout = Module["_stdout"] = 15435152; -var _timezone = Module["_timezone"] = 15899312; +var _timezone = Module["_timezone"] = 15900336; -var _tzname = Module["_tzname"] = 15899320; +var _tzname = Module["_tzname"] = 15900344; -var ___heap_base = 16961056; +var ___heap_base = 16962080; -var __ZNSt3__25ctypeIcE2idE = Module["__ZNSt3__25ctypeIcE2idE"] = 15912468; +var __ZNSt3__25ctypeIcE2idE = Module["__ZNSt3__25ctypeIcE2idE"] = 15913492; -var __ZTVN10__cxxabiv120__si_class_type_infoE = Module["__ZTVN10__cxxabiv120__si_class_type_infoE"] = 15434376; +var __ZTVN10__cxxabiv120__si_class_type_infoE = Module["__ZTVN10__cxxabiv120__si_class_type_infoE"] = 15435400; -var __ZTVN10__cxxabiv117__class_type_infoE = Module["__ZTVN10__cxxabiv117__class_type_infoE"] = 15434336; +var __ZTVN10__cxxabiv117__class_type_infoE = Module["__ZTVN10__cxxabiv117__class_type_infoE"] = 15435360; -var __ZTVN10__cxxabiv121__vmi_class_type_infoE = Module["__ZTVN10__cxxabiv121__vmi_class_type_infoE"] = 15434428; +var __ZTVN10__cxxabiv121__vmi_class_type_infoE = Module["__ZTVN10__cxxabiv121__vmi_class_type_infoE"] = 15435452; -var __ZTISt20bad_array_new_length = Module["__ZTISt20bad_array_new_length"] = 15434500; +var __ZTISt20bad_array_new_length = Module["__ZTISt20bad_array_new_length"] = 15435524; -var __ZTVSt12length_error = Module["__ZTVSt12length_error"] = 15434544; +var __ZTVSt12length_error = Module["__ZTVSt12length_error"] = 15435568; -var __ZTISt12length_error = Module["__ZTISt12length_error"] = 15434564; +var __ZTISt12length_error = Module["__ZTISt12length_error"] = 15435588; var wasmImports = { /** @export */ __assert_fail: ___assert_fail, diff --git a/packages/php-wasm/node-builds/8-4/jspi/8_4_19/php_8_4.wasm b/packages/php-wasm/node-builds/8-4/jspi/8_4_20/php_8_4.wasm similarity index 83% rename from packages/php-wasm/node-builds/8-4/jspi/8_4_19/php_8_4.wasm rename to packages/php-wasm/node-builds/8-4/jspi/8_4_20/php_8_4.wasm index 380454a2e65..c0d4fdafdd1 100755 Binary files a/packages/php-wasm/node-builds/8-4/jspi/8_4_19/php_8_4.wasm and b/packages/php-wasm/node-builds/8-4/jspi/8_4_20/php_8_4.wasm differ diff --git a/packages/php-wasm/node-builds/8-4/jspi/php_8_4.js b/packages/php-wasm/node-builds/8-4/jspi/php_8_4.js index 04af86250b0..d195c9d1769 100644 --- a/packages/php-wasm/node-builds/8-4/jspi/php_8_4.js +++ b/packages/php-wasm/node-builds/8-4/jspi/php_8_4.js @@ -13,10 +13,10 @@ const currentDirPath = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -const dependencyFilename = path.join(currentDirPath, '8_4_19', 'php_8_4.wasm'); +const dependencyFilename = path.join(currentDirPath, '8_4_20', 'php_8_4.wasm'); export { dependencyFilename }; -export const dependenciesTotalSize = 24732237; -const phpVersionString = '8.4.19'; +export const dependenciesTotalSize = 24738158; +const phpVersionString = '8.4.20'; export function init(RuntimeName, PHPLoader) { // The rest of the code comes from the built php.js file and esm-suffix.js // include: shell.js @@ -8766,7 +8766,7 @@ var Asyncify = { return wrapper; }, instrumentWasmExports(exports) { - var exportPattern = /^(php_wasm_init|wasm_sleep|wasm_read|emscripten_sleep|wasm_sapi_handle_request|wasm_sapi_request_shutdown|wasm_poll_socket|wrap_select|__wrap_select|select|php_pollfd_for|fflush|wasm_popen|wasm_read|wasm_php_exec|run_cli|wasm_recv|wasm_connect|__wasm_call_ctors|__errno_location|__funcs_on_exit|main|__main_argc_argv)$/; + var exportPattern = /^(php_wasm_init|wasm_sleep|wasm_read|emscripten_sleep|wasm_sapi_handle_request|wasm_sapi_request_shutdown|wasm_poll_socket|wrap_select|__wrap_select|select|php_pollfd_for|fflush|wasm_popen|wasm_pclose|wasm_read|wasm_php_exec|run_cli|wasm_recv|wasm_connect|__wasm_call_ctors|__errno_location|__funcs_on_exit|main|__main_argc_argv)$/; Asyncify.asyncExports = new Set; var ret = {}; for (let [x, original] of Object.entries(exports)) { @@ -9236,7 +9236,7 @@ function __asyncjs__js_module_onMessage(data, response_buffer) { __asyncjs__js_module_onMessage.sig = "iii"; // Imports from the Wasm binary. -var _php_date_get_date_ce, _php_date_get_interface_ce, _php_date_get_timezone_ce, _get_timezone_info, _php_hash_fetch_ops, _php_random_bytes, _php_combined_lcg, _php_mt_rand_range, _php_get_nan, _php_get_inf, _php_setcookie, _php_escape_html_entities, _php_info_print_table_header, _php_info_print_table_row, _php_info_print_table_start, _php_info_print_table_end, _php_info_print_table_colspan_header, _PHP_MD5Final, _PHP_MD5InitArgs, _PHP_MD5Update, _php_str_to_str, _php_addcslashes_str, _php_addcslashes, _php_var_unserialize_init, _php_var_unserialize_destroy, _php_var_unserialize, _php_var_serialize, _php_var_serialize_init, _php_var_serialize_destroy, _php_printf, _php_get_module_initialized, _php_log_err_with_severity, _php_error_docref, _php_socket_strerror, _php_output_write, _display_ini_entries, _sapi_header_op, _ap_php_slprintf, _ap_php_snprintf, _ap_php_vsnprintf, __php_stream_cast, __php_stream_free, __php_stream_read, __php_stream_eof, __php_stream_set_option, __php_stream_write, __php_stream_getc, __php_stream_get_line, __php_stream_seek, __php_stream_open_wrapper_ex, _php_stream_context_alloc, _php_stream_context_set_option, __php_stream_xport_create, __emalloc_16, __emalloc_24, __emalloc_32, __emalloc_40, __emalloc_48, __emalloc_56, __emalloc_80, __emalloc_96, __emalloc_128, __emalloc_160, __emalloc_192, __emalloc_320, __emalloc_1280, __efree_56, __emalloc, __efree, __erealloc, __safe_emalloc, ___zend_malloc, __safe_erealloc, ___zend_realloc, __ecalloc, __estrdup, __estrndup, _zend_set_memory_limit, _zend_memory_usage, _zend_memory_peak_usage, ___zend_calloc, _zend_get_parameters_array_ex, _zend_wrong_param_count, _zend_zval_value_name, _zend_zval_type_name, _zend_wrong_parameters_none_error, _zend_wrong_parameters_count_error, _zend_wrong_parameter_error, _zend_argument_type_error, _zend_argument_value_error, _zend_argument_error, _zend_argument_must_not_be_empty_error, _zend_parse_arg_bool_slow, _zend_flf_parse_arg_bool_slow, _zend_parse_arg_long_slow, _zend_flf_parse_arg_long_slow, _zend_parse_arg_double_slow, _zend_parse_arg_str_slow, _zend_flf_parse_arg_str_slow, _zend_parse_arg_str_or_long_slow, _zend_release_fcall_info_cache, _zend_parse_parameters, _zend_parse_method_parameters, _object_properties_init, _object_init_ex, _add_assoc_long_ex, _add_assoc_null_ex, _add_assoc_bool_ex, _add_assoc_double_ex, _add_assoc_str_ex, _add_assoc_string_ex, _add_assoc_stringl_ex, _add_assoc_zval_ex, _add_index_long, _add_index_null, _add_index_string, _add_index_stringl, _add_next_index_long, _add_next_index_null, _add_next_index_bool, _add_next_index_double, _add_next_index_str, _add_next_index_string, _add_next_index_stringl, _zend_startup_module, _zend_register_internal_class_ex, _zend_register_internal_class_with_flags, _zend_class_implements, _zend_is_callable_ex, _zend_fcall_info_init, _zend_get_module_version, _zend_declare_typed_property, _zend_try_assign_typed_ref_bool, _zend_try_assign_typed_ref_long, _zend_try_assign_typed_ref_str, _zend_try_assign_typed_ref_arr, _zend_declare_typed_class_constant, _zend_declare_class_constant_ex, _zend_declare_class_constant_long, _zend_declare_class_constant_bool, _zend_update_property, _zend_read_property_ex, _zend_read_property, _zend_replace_error_handling, _zend_restore_error_handling, _zend_get_parameter_attribute_str, _zend_add_attribute, _zend_get_closure_method_def, _zend_type_to_string, _zend_unmangle_property_name_ex, _zend_is_auto_global_str, _zend_get_compiled_variable_name, _zend_register_long_constant, _zend_register_string_constant, _zend_get_constant_str, _zend_get_exception_base, _zend_is_unwind_exit, _zend_is_graceful_exit, _zend_clear_exception, _zend_throw_exception, _zend_throw_exception_ex, _zend_throw_error_exception, _get_active_class_name, _get_active_function_name, _zend_get_executed_filename, _zend_get_executed_filename_ex, _zend_get_executed_lineno, __call_user_function_impl, _zend_call_function, _zend_call_known_function, _zend_call_known_instance_method_with_2_params, _zend_eval_string, _zend_set_timeout, _zend_unset_timeout, _zend_fetch_class, _zend_rebuild_symbol_table, _zend_get_zval_ptr, _zend_set_user_opcode_handler, _zend_get_user_opcode_handler, _zend_get_resource_handle, _gc_enabled, _gc_possible_root, _zend_gc_get_status, _zend_get_gc_buffer_create, _zend_get_gc_buffer_grow, _zend_hash_str_find, __zend_hash_init, __zend_new_array_0, __zend_new_array, _zend_array_dup, _zend_hash_add, _zend_hash_update, _zend_hash_str_update, _zend_hash_index_add_empty_element, _zend_hash_add_empty_element, _zend_hash_str_add_empty_element, _zend_hash_next_index_insert, _zend_hash_next_index_insert_new, _zend_hash_index_update, _zend_hash_del, _zend_hash_str_del, _zend_hash_index_del, _zend_hash_destroy, _zend_array_destroy, _zend_hash_clean, _zend_hash_apply, _zend_hash_apply_with_arguments, _zend_hash_copy, _zend_array_to_list, _zend_hash_find, _zend_hash_index_find, _zend_hash_internal_pointer_reset_ex, _zend_hash_move_forward_ex, _zend_hash_get_current_key_ex, _zend_hash_get_current_key_type_ex, _zend_hash_get_current_data_ex, _zend_hash_sort_ex, __zend_handle_numeric_str_ex, _zend_html_puts, _zend_register_ini_entries_ex, _zend_unregister_ini_entries_ex, _zend_alter_ini_entry, _zend_ini_long, _zend_ini_string_ex, _zend_ini_string, _zend_ini_boolean_displayer_cb, _display_link_numbers, _OnUpdateBool, _OnUpdateLong, _OnUpdateLongGEZero, _OnUpdateReal, _OnUpdateString, _OnUpdateStringUnempty, _zend_call_method, _zend_create_internal_iterator_zval, _zend_iterator_init, _zend_register_list_destructors_ex, _zend_rsrc_list_get_rsrc_type, _zend_register_persistent_resource, _zend_llist_init, _zend_llist_add_element, _zend_llist_prepend_element, _zend_llist_destroy, _zend_llist_remove_tail, _zend_llist_count, _zend_llist_get_first_ex, _zend_llist_get_last_ex, _zend_llist_get_next_ex, _zend_std_get_properties, _zend_get_properties_no_lazy_init, _zend_get_property_info, _zend_class_init_statics, _zend_std_compare_objects, _zend_get_properties_for, _zend_objects_store_mark_destructed, _zend_objects_store_del, _zend_object_std_init, _zend_object_std_dtor, _zend_objects_clone_members, _zend_observer_fcall_register, _zend_observer_fiber_switch_register, __is_numeric_string_ex, _zval_try_get_long, _convert_to_long, _zval_get_long_func, _convert_to_double, __convert_to_string, __try_convert_to_string, _zval_get_double_func, _zval_get_string_func, _zend_is_true, _zend_binary_strcasecmp, _numeric_compare_function, _compare_function, _instanceof_function_slow, _zend_str_tolower, _zend_memnstr_ex, _smart_str_erealloc, __smart_string_alloc, _zend_sort, _zend_strtod, _zend_freedtoa, _zend_dtoa, _rc_dtor_func, _zval_ptr_dtor, _zval_add_ref, _virtual_file_ex, _tsrm_realpath, _zend_vspprintf, _zend_spprintf, _zend_strpprintf, __zend_bailout, _zend_error, _zend_error_noreturn, _zend_throw_error, _zend_illegal_container_offset, _zend_argument_count_error, _zend_value_error, _strtoll, _strlen, _munmap, _fiprintf, _abort, _free, _memcmp, _malloc, _snprintf, _strchr, _clock_gettime, _dlopen, _dlsym, _dlclose, _strcmp, _getenv, ___wasm_setjmp, ___wasm_setjmp_test, ___wasm_longjmp, _atoi, ___errno_location, _strtoull, _strrchr, _realloc, _strcasecmp, _memchr, _fwrite, _strncmp, _iscntrl, _isxdigit, _tolower, _strtok_r, _strncasecmp, _fileno, _isatty, _fread, _fclose, _strtoul, _strstr, _strpbrk, _strdup, _write, _close, _stat, _gettimeofday, _time, _toupper, _iprintf, _puts, _putchar, _fopen, _getcwd, _open, _strncpy, _siprintf, _localtime_r, _strtol, _pow, _strtod, _strftime, _round, _sin, _cos, _atan2, _acos, _tan, _asin, _atan, _log, _log2, _fmod, _ispunct, _setlocale, _strerror, _read, _wasm_popen, _wasm_php_exec, _socket, _gai_strerror, _freeaddrinfo, _fcntl, _connect, _strerror_r, _php_pollfd_for, _getsockopt, _htons, _ntohs, _getpeername, _getsockname, _htonl, _send, _shutdown, _strcpy, _strcat, _tzset, _ntohl, _wasm_sleep, _fputs, _atoll, _isdigit, _isgraph, _isspace, _fflush, _calloc, _expf, _lseek, _fputc, ___small_fprintf, _qsort, _vfprintf, _mmap, _flock, _fgets, _initgroups, _atol, _wasm_read, _feof, _strncat, ___ctype_get_mb_cur_max, ___wrap_usleep, _poll, ___wrap_select, _wasm_set_sapi_name, _wasm_set_phpini_path, _wasm_add_cli_arg, _run_cli, _wasm_add_SERVER_entry, _wasm_add_ENV_entry, _wasm_set_query_string, _wasm_set_path_translated, _wasm_set_skip_shebang, _wasm_set_request_uri, _wasm_set_request_method, _wasm_set_request_host, _wasm_set_content_type, _wasm_set_request_body, _wasm_set_content_length, _wasm_set_cookies, _wasm_set_request_port, _wasm_sapi_request_shutdown, _wasm_sapi_handle_request, _php_wasm_init, _wasm_free, _wasm_get_end_offset, ___wrap_getpid, _wasm_trace, _srandom, _random, _vsnprintf, _pthread_mutex_init, _pthread_mutex_destroy, _pthread_mutex_lock, _pthread_mutex_unlock, _srand, _rand, _rewind, _modf, _atof, _gmtime, _pthread_cond_init, _pthread_cond_destroy, _pthread_cond_broadcast, ___extenddftf2, ___letf2, ___floatunditf, _div, ___funcs_on_exit, ___cxa_atexit, ___dl_seterr, __emscripten_find_dylib, _freopen, _pthread_cond_timedwait, _mbstowcs, _emscripten_builtin_memalign, __emscripten_timeout, _strtok, _tanhf, _wcstombs, _emscripten_get_sbrk_ptr, ___trap, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, __ZNSt3__211__call_onceERVmPvPFvS2_E, __ZNSt3__218condition_variable10notify_allEv, __ZNSt3__25mutex4lockEv, __ZNSt3__25mutex6unlockEv, ___cxa_bad_typeid, ___cxa_allocate_exception, ___cxa_throw, ___cxa_pure_virtual, ___dynamic_cast, __ZNSt20bad_array_new_lengthD1Ev, __ZNSt12length_errorD1Ev, _sendmsg, memory, ___stack_pointer, __indirect_function_table, ___c_longjmp, wasmTable, wasmMemory; +var _php_date_get_date_ce, _php_date_get_interface_ce, _php_date_get_timezone_ce, _get_timezone_info, _php_hash_fetch_ops, _php_random_bytes, _php_combined_lcg, _php_mt_rand_range, _php_get_nan, _php_get_inf, _php_setcookie, _php_escape_html_entities, _php_info_print_table_header, _php_info_print_table_row, _php_info_print_table_start, _php_info_print_table_end, _php_info_print_table_colspan_header, _PHP_MD5Final, _PHP_MD5InitArgs, _PHP_MD5Update, _php_str_to_str, _php_addcslashes_str, _php_addcslashes, _php_var_unserialize_init, _php_var_unserialize_destroy, _php_var_unserialize, _php_var_serialize, _php_var_serialize_init, _php_var_serialize_destroy, _php_printf, _php_get_module_initialized, _php_log_err_with_severity, _php_error_docref, _php_socket_strerror, _php_output_write, _display_ini_entries, _sapi_header_op, _ap_php_slprintf, _ap_php_snprintf, _ap_php_vsnprintf, __php_stream_cast, __php_stream_free, __php_stream_read, __php_stream_eof, __php_stream_set_option, __php_stream_write, __php_stream_getc, __php_stream_get_line, __php_stream_seek, __php_stream_open_wrapper_ex, _php_stream_context_alloc, _php_stream_context_set_option, __php_stream_xport_create, __emalloc_16, __emalloc_24, __emalloc_32, __emalloc_40, __emalloc_48, __emalloc_56, __emalloc_80, __emalloc_96, __emalloc_128, __emalloc_160, __emalloc_192, __emalloc_320, __emalloc_1280, __efree_56, __emalloc, __efree, __erealloc, __safe_emalloc, ___zend_malloc, __safe_erealloc, ___zend_realloc, __ecalloc, __estrdup, __estrndup, _zend_set_memory_limit, _zend_memory_usage, _zend_memory_peak_usage, ___zend_calloc, _zend_get_parameters_array_ex, _zend_wrong_param_count, _zend_zval_value_name, _zend_zval_type_name, _zend_wrong_parameters_none_error, _zend_wrong_parameters_count_error, _zend_wrong_parameter_error, _zend_argument_type_error, _zend_argument_value_error, _zend_argument_error, _zend_argument_must_not_be_empty_error, _zend_parse_arg_bool_slow, _zend_flf_parse_arg_bool_slow, _zend_parse_arg_long_slow, _zend_flf_parse_arg_long_slow, _zend_parse_arg_double_slow, _zend_parse_arg_str_slow, _zend_flf_parse_arg_str_slow, _zend_parse_arg_str_or_long_slow, _zend_release_fcall_info_cache, _zend_parse_parameters, _zend_parse_method_parameters, _object_properties_init, _object_init_ex, _add_assoc_long_ex, _add_assoc_null_ex, _add_assoc_bool_ex, _add_assoc_double_ex, _add_assoc_str_ex, _add_assoc_string_ex, _add_assoc_stringl_ex, _add_assoc_zval_ex, _add_index_long, _add_index_null, _add_index_string, _add_index_stringl, _add_next_index_long, _add_next_index_null, _add_next_index_bool, _add_next_index_double, _add_next_index_str, _add_next_index_string, _add_next_index_stringl, _zend_startup_module, _zend_register_internal_class_ex, _zend_register_internal_class_with_flags, _zend_class_implements, _zend_is_callable_ex, _zend_fcall_info_init, _zend_get_module_version, _zend_declare_typed_property, _zend_try_assign_typed_ref_bool, _zend_try_assign_typed_ref_long, _zend_try_assign_typed_ref_str, _zend_try_assign_typed_ref_arr, _zend_declare_typed_class_constant, _zend_declare_class_constant_ex, _zend_declare_class_constant_long, _zend_declare_class_constant_bool, _zend_update_property, _zend_read_property_ex, _zend_read_property, _zend_replace_error_handling, _zend_restore_error_handling, _zend_get_parameter_attribute_str, _zend_add_attribute, _zend_get_closure_method_def, _zend_type_to_string, _zend_unmangle_property_name_ex, _zend_is_auto_global_str, _zend_get_compiled_variable_name, _zend_register_long_constant, _zend_register_string_constant, _zend_get_constant_str, _zend_get_exception_base, _zend_is_unwind_exit, _zend_is_graceful_exit, _zend_clear_exception, _zend_throw_exception, _zend_throw_exception_ex, _zend_throw_error_exception, _get_active_class_name, _get_active_function_name, _zend_get_executed_filename, _zend_get_executed_filename_ex, _zend_get_executed_lineno, __call_user_function_impl, _zend_call_function, _zend_call_known_function, _zend_call_known_instance_method_with_2_params, _zend_eval_string, _zend_set_timeout, _zend_unset_timeout, _zend_fetch_class, _zend_rebuild_symbol_table, _zend_get_zval_ptr, _zend_set_user_opcode_handler, _zend_get_user_opcode_handler, _zend_get_resource_handle, _gc_enabled, _gc_possible_root, _zend_gc_get_status, _zend_get_gc_buffer_create, _zend_get_gc_buffer_grow, _zend_hash_str_find, __zend_hash_init, __zend_new_array_0, __zend_new_array, _zend_array_dup, _zend_hash_add, _zend_hash_update, _zend_hash_str_update, _zend_hash_index_add_empty_element, _zend_hash_add_empty_element, _zend_hash_str_add_empty_element, _zend_hash_next_index_insert, _zend_hash_next_index_insert_new, _zend_hash_index_update, _zend_hash_del, _zend_hash_str_del, _zend_hash_index_del, _zend_hash_destroy, _zend_array_destroy, _zend_hash_clean, _zend_hash_apply, _zend_hash_apply_with_arguments, _zend_hash_copy, _zend_array_to_list, _zend_hash_find, _zend_hash_index_find, _zend_hash_internal_pointer_reset_ex, _zend_hash_move_forward_ex, _zend_hash_get_current_key_ex, _zend_hash_get_current_key_type_ex, _zend_hash_get_current_data_ex, _zend_hash_sort_ex, __zend_handle_numeric_str_ex, _zend_html_puts, _zend_register_ini_entries_ex, _zend_unregister_ini_entries_ex, _zend_alter_ini_entry, _zend_ini_long, _zend_ini_string_ex, _zend_ini_string, _zend_ini_boolean_displayer_cb, _display_link_numbers, _OnUpdateBool, _OnUpdateLong, _OnUpdateLongGEZero, _OnUpdateReal, _OnUpdateString, _OnUpdateStringUnempty, _zend_call_method, _zend_create_internal_iterator_zval, _zend_iterator_init, _zend_register_list_destructors_ex, _zend_rsrc_list_get_rsrc_type, _zend_register_persistent_resource, _zend_llist_init, _zend_llist_add_element, _zend_llist_prepend_element, _zend_llist_destroy, _zend_llist_remove_tail, _zend_llist_count, _zend_llist_get_first_ex, _zend_llist_get_last_ex, _zend_llist_get_next_ex, _zend_std_get_properties, _zend_get_properties_no_lazy_init, _zend_get_property_info, _zend_class_init_statics, _zend_std_compare_objects, _zend_get_properties_for, _zend_objects_store_mark_destructed, _zend_objects_store_del, _zend_object_std_init, _zend_object_std_dtor, _zend_objects_clone_members, _zend_observer_fcall_register, _zend_observer_fiber_switch_register, __is_numeric_string_ex, _zval_try_get_long, _convert_to_long, _zval_get_long_func, _convert_to_double, __convert_to_string, __try_convert_to_string, _zval_get_double_func, _zval_get_string_func, _zend_is_true, _zend_binary_strcasecmp, _numeric_compare_function, _compare_function, _instanceof_function_slow, _zend_str_tolower, _zend_memnstr_ex, _smart_str_erealloc, __smart_string_alloc, _zend_sort, _zend_strtod, _zend_freedtoa, _zend_dtoa, _rc_dtor_func, _zval_ptr_dtor, _zval_add_ref, _virtual_file_ex, _tsrm_realpath, _zend_vspprintf, _zend_spprintf, _zend_strpprintf, __zend_bailout, _zend_error, _zend_error_noreturn, _zend_throw_error, _zend_illegal_container_offset, _zend_argument_count_error, _zend_value_error, _strtoll, _strlen, _munmap, _fiprintf, _abort, _free, _memcmp, _malloc, _snprintf, _strchr, _clock_gettime, _dlopen, _dlsym, _dlclose, _strcmp, _getenv, ___wasm_setjmp, ___wasm_setjmp_test, ___wasm_longjmp, _atoi, ___errno_location, _strtoull, _strrchr, _realloc, _strcasecmp, _memchr, _fwrite, _strncmp, _iscntrl, _isxdigit, _tolower, _strtok_r, _strncasecmp, _fileno, _isatty, _fread, _fclose, _strtoul, _strstr, _strpbrk, _strdup, _write, _close, _stat, _gettimeofday, _time, _toupper, _iprintf, _puts, _putchar, _fopen, _getcwd, _open, _strncpy, _siprintf, _localtime_r, _strtol, _pow, _strtod, _strftime, _round, _sin, _cos, _atan2, _acos, _tan, _asin, _atan, _log, _log2, _fmod, _ispunct, _setlocale, _strerror, _read, _wasm_popen, _wasm_php_exec, _socket, _gai_strerror, _freeaddrinfo, _fcntl, _connect, _strerror_r, _php_pollfd_for, _getsockopt, _htons, _ntohs, _getpeername, _getsockname, _htonl, _send, _shutdown, _strcpy, _strcat, _wasm_pclose, _tzset, _ntohl, _wasm_sleep, _fputs, _atoll, _isdigit, _isgraph, _isspace, _fflush, _calloc, _expf, _lseek, _fputc, ___small_fprintf, _qsort, _vfprintf, _mmap, _flock, _fgets, _initgroups, _atol, _wasm_read, _feof, _strncat, ___ctype_get_mb_cur_max, ___wrap_usleep, _poll, ___wrap_select, _wasm_set_sapi_name, _wasm_set_phpini_path, _wasm_add_cli_arg, _run_cli, _wasm_add_SERVER_entry, _wasm_add_ENV_entry, _wasm_set_query_string, _wasm_set_path_translated, _wasm_set_skip_shebang, _wasm_set_request_uri, _wasm_set_request_method, _wasm_set_request_host, _wasm_set_content_type, _wasm_set_request_body, _wasm_set_content_length, _wasm_set_cookies, _wasm_set_request_port, _wasm_sapi_request_shutdown, _wasm_sapi_handle_request, _php_wasm_init, _wasm_free, _wasm_get_end_offset, ___wrap_getpid, _wasm_trace, _srandom, _random, _vsnprintf, _pthread_mutex_init, _pthread_mutex_destroy, _pthread_mutex_lock, _pthread_mutex_unlock, _srand, _rand, _rewind, _modf, _atof, _gmtime, _pthread_cond_init, _pthread_cond_destroy, _pthread_cond_broadcast, ___extenddftf2, ___letf2, ___floatunditf, _div, ___funcs_on_exit, ___cxa_atexit, ___dl_seterr, __emscripten_find_dylib, _freopen, _pthread_cond_timedwait, _mbstowcs, _emscripten_builtin_memalign, __emscripten_timeout, _strtok, _tanhf, _wcstombs, _emscripten_get_sbrk_ptr, ___trap, __emscripten_stack_restore, __emscripten_stack_alloc, _emscripten_stack_get_current, __ZNSt3__211__call_onceERVmPvPFvS2_E, __ZNSt3__218condition_variable10notify_allEv, __ZNSt3__25mutex4lockEv, __ZNSt3__25mutex6unlockEv, ___cxa_bad_typeid, ___cxa_allocate_exception, ___cxa_throw, ___cxa_pure_virtual, ___dynamic_cast, __ZNSt20bad_array_new_lengthD1Ev, __ZNSt12length_errorD1Ev, _sendmsg, memory, ___stack_pointer, __indirect_function_table, ___c_longjmp, wasmTable, wasmMemory; function assignWasmExports(wasmExports) { _php_date_get_date_ce = Module["_php_date_get_date_ce"] = wasmExports["php_date_get_date_ce"]; @@ -9631,6 +9631,7 @@ function assignWasmExports(wasmExports) { _shutdown = Module["_shutdown"] = wasmExports["shutdown"]; _strcpy = Module["_strcpy"] = wasmExports["strcpy"]; _strcat = Module["_strcat"] = wasmExports["strcat"]; + _wasm_pclose = Module["_wasm_pclose"] = wasmExports["wasm_pclose"]; _tzset = Module["_tzset"] = wasmExports["tzset"]; _ntohl = Module["_ntohl"] = wasmExports["ntohl"]; _wasm_sleep = Module["_wasm_sleep"] = wasmExports["wasm_sleep"]; @@ -9738,109 +9739,109 @@ function assignWasmExports(wasmExports) { ___c_longjmp = Module["___c_longjmp"] = wasmExports["__c_longjmp"]; } -var _spl_ce_RuntimeException = Module["_spl_ce_RuntimeException"] = 15548508; +var _spl_ce_RuntimeException = Module["_spl_ce_RuntimeException"] = 15549532; -var _core_globals = Module["_core_globals"] = 15559408; +var _core_globals = Module["_core_globals"] = 15560432; -var _php_ini_opened_path = Module["_php_ini_opened_path"] = 15448288; +var _php_ini_opened_path = Module["_php_ini_opened_path"] = 15449312; -var _php_ini_scanned_path = Module["_php_ini_scanned_path"] = 15448292; +var _php_ini_scanned_path = Module["_php_ini_scanned_path"] = 15449316; -var _php_ini_scanned_files = Module["_php_ini_scanned_files"] = 15448296; +var _php_ini_scanned_files = Module["_php_ini_scanned_files"] = 15449320; -var _sapi_module = Module["_sapi_module"] = 15443464; +var _sapi_module = Module["_sapi_module"] = 15444488; -var _sapi_globals = Module["_sapi_globals"] = 15443608; +var _sapi_globals = Module["_sapi_globals"] = 15444632; -var _module_registry = Module["_module_registry"] = 15561368; +var _module_registry = Module["_module_registry"] = 15562392; -var _zend_ce_closure = Module["_zend_ce_closure"] = 15555020; +var _zend_ce_closure = Module["_zend_ce_closure"] = 15556044; -var _compiler_globals = Module["_compiler_globals"] = 15562920; +var _compiler_globals = Module["_compiler_globals"] = 15563944; -var _executor_globals = Module["_executor_globals"] = 15563336; +var _executor_globals = Module["_executor_globals"] = 15564360; -var _zend_compile_file = Module["_zend_compile_file"] = 15564728; +var _zend_compile_file = Module["_zend_compile_file"] = 15565752; -var _zend_ce_exception = Module["_zend_ce_exception"] = 15560492; +var _zend_ce_exception = Module["_zend_ce_exception"] = 15561516; -var _zend_ce_error = Module["_zend_ce_error"] = 15560608; +var _zend_ce_error = Module["_zend_ce_error"] = 15561632; -var _zend_throw_exception_hook = Module["_zend_throw_exception_hook"] = 15560484; +var _zend_throw_exception_hook = Module["_zend_throw_exception_hook"] = 15561508; -var _zend_ce_throwable = Module["_zend_ce_throwable"] = 15560488; +var _zend_ce_throwable = Module["_zend_ce_throwable"] = 15561512; -var _zend_execute_ex = Module["_zend_execute_ex"] = 15561748; +var _zend_execute_ex = Module["_zend_execute_ex"] = 15562772; -var _zend_execute_internal = Module["_zend_execute_internal"] = 15561752; +var _zend_execute_internal = Module["_zend_execute_internal"] = 15562776; -var _empty_fcall_info = Module["_empty_fcall_info"] = 11259104; +var _empty_fcall_info = Module["_empty_fcall_info"] = 11260064; -var _empty_fcall_info_cache = Module["_empty_fcall_info_cache"] = 11259152; +var _empty_fcall_info_cache = Module["_empty_fcall_info_cache"] = 11260112; -var _zend_pass_function = Module["_zend_pass_function"] = 14925280; +var _zend_pass_function = Module["_zend_pass_function"] = 14926304; -var _zend_extensions = Module["_zend_extensions"] = 15560236; +var _zend_extensions = Module["_zend_extensions"] = 15561260; -var _gc_collect_cycles = Module["_gc_collect_cycles"] = 15561744; +var _gc_collect_cycles = Module["_gc_collect_cycles"] = 15562768; -var _zend_empty_array = Module["_zend_empty_array"] = 14940192; +var _zend_empty_array = Module["_zend_empty_array"] = 14941216; -var _zend_ce_aggregate = Module["_zend_ce_aggregate"] = 15442832; +var _zend_ce_aggregate = Module["_zend_ce_aggregate"] = 15443856; -var _zend_ce_iterator = Module["_zend_ce_iterator"] = 15442836; +var _zend_ce_iterator = Module["_zend_ce_iterator"] = 15443860; -var _zend_ce_countable = Module["_zend_ce_countable"] = 15442848; +var _zend_ce_countable = Module["_zend_ce_countable"] = 15443872; -var _std_object_handlers = Module["_std_object_handlers"] = 14939680; +var _std_object_handlers = Module["_std_object_handlers"] = 14940704; -var _zend_empty_string = Module["_zend_empty_string"] = 15441344; +var _zend_empty_string = Module["_zend_empty_string"] = 15442368; -var _zend_known_strings = Module["_zend_known_strings"] = 15441348; +var _zend_known_strings = Module["_zend_known_strings"] = 15442372; -var _zend_string_init_interned = Module["_zend_string_init_interned"] = 15441412; +var _zend_string_init_interned = Module["_zend_string_init_interned"] = 15442436; -var _zend_one_char_string = Module["_zend_one_char_string"] = 15441424; +var _zend_one_char_string = Module["_zend_one_char_string"] = 15442448; -var _zend_write = Module["_zend_write"] = 15562836; +var _zend_write = Module["_zend_write"] = 15563860; -var _zend_error_cb = Module["_zend_error_cb"] = 15562840; +var _zend_error_cb = Module["_zend_error_cb"] = 15563864; -var _zend_post_startup_cb = Module["_zend_post_startup_cb"] = 15562808; +var _zend_post_startup_cb = Module["_zend_post_startup_cb"] = 15563832; var ___memory_base = Module["___memory_base"] = 0; var ___table_base = Module["___table_base"] = 1; -var _stderr = Module["_stderr"] = 15434224; +var _stderr = Module["_stderr"] = 15435248; -var _stdout = Module["_stdout"] = 15434528; +var _stdout = Module["_stdout"] = 15435552; -var _stdin = Module["_stdin"] = 15434376; +var _stdin = Module["_stdin"] = 15435400; -var _z_errmsg = Module["_z_errmsg"] = 14941872; +var _z_errmsg = Module["_z_errmsg"] = 14942896; -var _timezone = Module["_timezone"] = 15900016; +var _timezone = Module["_timezone"] = 15901040; -var _tzname = Module["_tzname"] = 15900024; +var _tzname = Module["_tzname"] = 15901048; -var ___heap_base = 16962288; +var ___heap_base = 16963312; -var __ZNSt3__25ctypeIcE2idE = Module["__ZNSt3__25ctypeIcE2idE"] = 15913692; +var __ZNSt3__25ctypeIcE2idE = Module["__ZNSt3__25ctypeIcE2idE"] = 15914716; -var __ZSt7nothrow = Module["__ZSt7nothrow"] = 13709290; +var __ZSt7nothrow = Module["__ZSt7nothrow"] = 13710314; -var __ZTVN10__cxxabiv120__si_class_type_infoE = Module["__ZTVN10__cxxabiv120__si_class_type_infoE"] = 15434816; +var __ZTVN10__cxxabiv120__si_class_type_infoE = Module["__ZTVN10__cxxabiv120__si_class_type_infoE"] = 15435840; -var __ZTVN10__cxxabiv117__class_type_infoE = Module["__ZTVN10__cxxabiv117__class_type_infoE"] = 15434776; +var __ZTVN10__cxxabiv117__class_type_infoE = Module["__ZTVN10__cxxabiv117__class_type_infoE"] = 15435800; -var __ZTVN10__cxxabiv121__vmi_class_type_infoE = Module["__ZTVN10__cxxabiv121__vmi_class_type_infoE"] = 15434868; +var __ZTVN10__cxxabiv121__vmi_class_type_infoE = Module["__ZTVN10__cxxabiv121__vmi_class_type_infoE"] = 15435892; -var __ZTISt20bad_array_new_length = Module["__ZTISt20bad_array_new_length"] = 15434988; +var __ZTISt20bad_array_new_length = Module["__ZTISt20bad_array_new_length"] = 15436012; -var __ZTVSt12length_error = Module["__ZTVSt12length_error"] = 15435064; +var __ZTVSt12length_error = Module["__ZTVSt12length_error"] = 15436088; -var __ZTISt12length_error = Module["__ZTISt12length_error"] = 15435084; +var __ZTISt12length_error = Module["__ZTISt12length_error"] = 15436108; var wasmImports = { /** @export */ __assert_fail: ___assert_fail, diff --git a/packages/php-wasm/node/project.json b/packages/php-wasm/node/project.json index 6da9b9ec9d8..e1e2c4c1ead 100644 --- a/packages/php-wasm/node/project.json +++ b/packages/php-wasm/node/project.json @@ -170,7 +170,8 @@ "php-imagick.spec.ts", "php-soap.spec.ts", "php-image-extensions.spec.ts", - "php-fsockopen.spec.ts" + "php-fsockopen.spec.ts", + "php-smtp.spec.ts" ] } }, @@ -184,7 +185,8 @@ "php-imagick.spec.ts", "php-soap.spec.ts", "php-image-extensions.spec.ts", - "php-fsockopen.spec.ts" + "php-fsockopen.spec.ts", + "php-smtp.spec.ts" ] } }, diff --git a/packages/php-wasm/node/src/lib/load-runtime.ts b/packages/php-wasm/node/src/lib/load-runtime.ts index b23f5f40dec..78daaabd087 100644 --- a/packages/php-wasm/node/src/lib/load-runtime.ts +++ b/packages/php-wasm/node/src/lib/load-runtime.ts @@ -22,7 +22,9 @@ import { import { withIntl } from './extensions/intl/with-intl'; import { withRedis } from './extensions/redis/with-redis'; import { withMemcached } from './extensions/memcached/with-memcached'; +import { withSMTPSink } from '@php-wasm/universal'; import { dirname, joinPaths, toPosixPath } from '@php-wasm/util'; +import type { CaughtMessage } from '@php-wasm/util'; import { platform } from 'os'; export interface PHPLoaderOptions { @@ -31,6 +33,7 @@ export interface PHPLoaderOptions { withIntl?: boolean; withRedis?: boolean; withMemcached?: boolean; + withSMTPSink?: { port: number; onEmail: (m: CaughtMessage) => void }; } export type PHPLoaderOptionsForNode = PHPLoaderOptions & { @@ -308,6 +311,12 @@ export async function loadNodeRuntime( } emscriptenOptions = await withNetworking(emscriptenOptions); + if (options?.withSMTPSink) { + emscriptenOptions = withSMTPSink( + options.withSMTPSink, + emscriptenOptions + ); + } const phpLoaderModule = await getPHPLoaderModule(phpVersion); diff --git a/packages/php-wasm/node/src/test/php-smtp.spec.ts b/packages/php-wasm/node/src/test/php-smtp.spec.ts new file mode 100644 index 00000000000..f7693a9ebf3 --- /dev/null +++ b/packages/php-wasm/node/src/test/php-smtp.spec.ts @@ -0,0 +1,132 @@ +import { PHP, setPhpIniEntries } from '@php-wasm/universal'; +import type { CaughtMessage } from '@php-wasm/util'; +import { loadNodeRuntime } from '../lib'; + +const phpVersions = ['8.4']; +// TODO re-enable testing on all versions before merging +// 'PHP' in process.env +// ? [process.env['PHP']! as SupportedPHPVersion] +// : SupportedPHPVersions; + +describe.each(phpVersions)('PHP %s – SMTP sink', (phpVersion) => { + let php: PHP; + let emails: CaughtMessage[]; + + beforeEach(async () => { + emails = []; + php = new PHP( + await loadNodeRuntime(phpVersion as any, { + withSMTPSink: { + port: 25, + onEmail: (m: CaughtMessage) => emails.push(m), + }, + }) + ); + await setPhpIniEntries(php, { + disable_functions: '', + allow_url_fopen: 1, + }); + }, 30_000); + + afterEach(() => { + php?.exit(); + }); + + it('captures an email piped via proc_open to sendmail', async () => { + const result = await php.run({ + code: ` { + const result = await php.run({ + code: `\\r\\n"); + smtp_read_reply($smtp); + fwrite($smtp, "RCPT TO:\\r\\n"); + smtp_read_reply($smtp); + fwrite($smtp, "DATA\\r\\n"); + smtp_read_reply($smtp); + fwrite($smtp, "From: sender@test.com\\r\\n"); + fwrite($smtp, "To: recipient@test.com\\r\\n"); + fwrite($smtp, "Subject: Hello via SMTP\\r\\n"); + fwrite($smtp, "\\r\\n"); + fwrite($smtp, "This is the body.\\r\\n"); + fwrite($smtp, ".\\r\\n"); + smtp_read_reply($smtp); + fwrite($smtp, "QUIT\\r\\n"); + fclose($smtp); + echo 'SENT'; + `, + }); + + expect(result.text).toBe('SENT'); + expect(emails).toHaveLength(1); + expect(emails[0].from).toContain('sender@test.com'); + expect(emails[0].to).toContain('recipient@test.com'); + expect(emails[0].subject).toBe('Hello via SMTP'); + expect(emails[0].text?.trim()).toBe('This is the body.'); + }); + + it('captures an email sent via mail()', async () => { + const result = await php.run({ + code: ` void; +}; + +/** + * Captures outbound email from PHP via two interception points: + * 1. `spawnProcess` — catches `mail()` calls that shell out to sendmail. + * 2. `websocket.decorator` — catches TCP connections to the given SMTP + * port and routes them through an in-process SmtpSink. + * + * Merges into the provided `emscriptenOptions`, chaining the websocket + * decorator and using any existing `spawnProcess` as a fallback for + * non-sendmail commands. + * + * Works in both Web and Node runtimes since both hooks are part of the + * shared EmscriptenOptions surface. + */ +export function withSMTPSink( + { port, onEmail }: WithSmtpSinkOptions, + emscriptenOptions: EmscriptenOptions = {} +): EmscriptenOptions { + // TODO: Provide a way for the Playground website to read received messages. + const prevWs = emscriptenOptions['websocket'] || {}; + const prevDecorator = prevWs.decorator as ((Base: any) => any) | undefined; + + const smtpDecorator = (BaseWebSocketConstructor: any) => { + return class SMTPDecoratedWebSocket extends BaseWebSocketConstructor { + constructor(url: string, wsOptions?: any) { + let targetPort = -1; + try { + const u = new URL(url); + targetPort = parseInt( + u.searchParams.get('port') || '-1', + 10 + ); + } catch { + // Ignore URL parse errors + } + + if (targetPort === port) { + // Returning an object from a constructor + // bypasses `this`, avoiding a super() call + // that would open a real connection to the + // SMTP port. + return new SmtpSinkWebSocket(url, onEmail) as any; + } + + super(url, wsOptions); + } + }; + }; + + return { + ...emscriptenOptions, + spawnProcess: createSendmailSpawnHandler( + onEmail, + emscriptenOptions['spawnProcess'] + ), + websocket: { + ...prevWs, + decorator: (Base: any) => { + const AfterPrev = prevDecorator ? prevDecorator(Base) : Base; + return smtpDecorator(AfterPrev); + }, + }, + }; +} diff --git a/packages/php-wasm/util/src/lib/create-sendmail-handler.ts b/packages/php-wasm/util/src/lib/create-sendmail-handler.ts new file mode 100644 index 00000000000..9b4619041e2 --- /dev/null +++ b/packages/php-wasm/util/src/lib/create-sendmail-handler.ts @@ -0,0 +1,127 @@ +import { createSpawnHandler } from './create-spawn-handler'; +import { parseMessage } from './smtp'; +import type { CaughtMessage } from './smtp'; + +/** + * Intercepts PHP's mail() function and routes the outgoing message to + * `onEmail`. + * + * PHP's mail() pipes a fully-formed message to the program in php.ini's + * `sendmail_path`, which defaults to `/usr/sbin/sendmail -t -i`. The `-t` + * means a real sendmail would read recipients from the To/Cc/Bcc headers, + * and this handler relies on that — it always extracts recipients from the + * headers rather than from command-line arguments. + * + * Any command whose binary basename is `sendmail` is matched. Other + * commands are forwarded to `fallbackSpawnHandler` if provided, otherwise + * they throw. + */ +const DEFAULT_MAX_SIZE = 10 * 1024 * 1024; // 10 MB, same as SmtpSink + +export function createSendmailSpawnHandler( + onEmail: (message: CaughtMessage) => void, + fallbackSpawnHandler?: ( + command: any, + argsArray?: any, + options?: any + ) => any, + { maxSize = DEFAULT_MAX_SIZE }: { maxSize?: number } = {} +) { + const sendmailHandler = createSpawnHandler( + async function (command, processApi) { + let envelopeSender = ''; + for (let i = 1; i < command.length; i++) { + if (command[i] === '-f' && i + 1 < command.length) { + envelopeSender = command[++i]; + } else if ( + command[i].startsWith('-f') && + command[i].length > 2 + ) { + envelopeSender = command[i].slice(2); + } + } + + const chunks: Uint8Array[] = []; + let totalLen = 0; + let overflow = false; + const stdinDone = new Promise((resolve) => { + processApi.childProcess.stdin.on('finish', resolve); + }); + processApi.on('stdin', (data: Uint8Array) => { + if (overflow) return; + totalLen += data.length; + if (totalLen > maxSize) { + overflow = true; + chunks.length = 0; + return; + } + chunks.push(data.slice()); + }); + + await stdinDone; + + if (overflow) { + processApi.stderr( + `sendmail: message exceeds maximum size (${maxSize} bytes)\n` + ); + processApi.exit(1); + return; + } + + const all = new Uint8Array(totalLen); + let offset = 0; + for (const c of chunks) { + all.set(c, offset); + offset += c.length; + } + const rawText = new TextDecoder().decode(all); + + if (!rawText.trim()) { + processApi.exit(0); + return; + } + + // Normalize line endings to CRLF for the email parsers + const raw = rawText.replace(/\r?\n/g, '\r\n'); + + const parsed = parseMessage(raw, envelopeSender, []); + + const message: CaughtMessage = { + receivedAt: new Date().toISOString(), + from: parsed.from, + to: parsed.to, + subject: parsed.subject, + headers: parsed.headers, + text: parsed.text, + raw, + rawSize: raw.length, + }; + + onEmail(message); + + processApi.exit(0); + } + ); + + return function ( + command: string | string[], + argsArray: string[] = [], + options: any = {} + ) { + const cmdStr = Array.isArray(command) + ? command[0] + : typeof command === 'string' + ? command.split(/\s+/)[0] + : ''; + const bin = cmdStr.split('/').pop() || ''; + if (bin !== 'sendmail') { + if (fallbackSpawnHandler) { + return fallbackSpawnHandler(command, argsArray, options); + } + throw new Error( + `createSendmailSpawnHandler: not a sendmail command: ${cmdStr}` + ); + } + return sendmailHandler(command, argsArray, options); + }; +} diff --git a/packages/php-wasm/util/src/lib/index.ts b/packages/php-wasm/util/src/lib/index.ts index 416d78b5ac6..5c247e410a8 100644 --- a/packages/php-wasm/util/src/lib/index.ts +++ b/packages/php-wasm/util/src/lib/index.ts @@ -12,12 +12,16 @@ export { toPosixPath, } from './paths'; export { createSpawnHandler } from './create-spawn-handler'; +export { createSendmailSpawnHandler } from './create-sendmail-handler'; export { randomString } from './random-string'; export { randomFilename } from './random-filename'; export { splitShellCommand } from './split-shell-command'; export { WritablePolyfill, type WritableOptions } from './writable-polyfill'; export { EventEmitterPolyfill } from './event-emitter-polyfill'; +export { WebSocketShim } from './websocket-shim'; +export { SmtpSinkWebSocket } from './smtp-sink-websocket'; export * from './php-vars'; +export * from './smtp'; export * from './sprintf'; diff --git a/packages/php-wasm/util/src/lib/smtp-sink-websocket.ts b/packages/php-wasm/util/src/lib/smtp-sink-websocket.ts new file mode 100644 index 00000000000..6c01df41f2e --- /dev/null +++ b/packages/php-wasm/util/src/lib/smtp-sink-websocket.ts @@ -0,0 +1,75 @@ +import { WebSocketShim } from './websocket-shim'; +import { SmtpSink, makeLoopbackPair, type CaughtMessage } from './smtp'; + +/** + * A WebSocket-shaped class that pipes outbound bytes through an in-process + * SmtpSink instead of opening a real network connection. Used to intercept + * Emscripten's SMTP-bound TCP traffic. + */ +export class SmtpSinkWebSocket extends WebSocketShim { + private writer: WritableStreamDefaultWriter; + private pendingWrites: Uint8Array[] | null = []; + + constructor(url: string, onEmail: (message: CaughtMessage) => void) { + super(url); + + const [client, server] = makeLoopbackPair(); + void new SmtpSink(server, onEmail).start(); + this.writer = client.writable.getWriter(); + + this.emitOpen(); + client.readable + .pipeTo( + new WritableStream({ + write: (chunk) => this.emitMessage(chunk), + }) + ) + .finally(() => { + if (this.readyState !== this.CLOSED) this.emitClose(); + }); + } + + override emitOpen() { + super.emitOpen(); + const buffered = this.pendingWrites; + this.pendingWrites = null; + if (buffered) { + for (const bytes of buffered) { + this.writeToStream(bytes); + } + } + } + + override send(data: ArrayBuffer | Uint8Array | string) { + const bytes = + typeof data === 'string' + ? new TextEncoder().encode(data) + : data instanceof ArrayBuffer + ? new Uint8Array(data) + : data; + + if (this.readyState === this.CONNECTING) { + this.pendingWrites!.push(bytes); + return; + } + if (this.readyState !== this.OPEN) { + this.emitError( + new Error( + `SmtpSinkWebSocket: send() called in state ${this.readyState}` + ) + ); + return; + } + this.writeToStream(bytes); + } + + private writeToStream(bytes: Uint8Array) { + void this.writer.write(bytes); + } + + override close() { + if (this.readyState >= this.CLOSING) return; + this.readyState = this.CLOSING; + void this.writer.close(); + } +} diff --git a/packages/php-wasm/util/src/lib/smtp.test.ts b/packages/php-wasm/util/src/lib/smtp.test.ts new file mode 100644 index 00000000000..81b21b8a3a9 --- /dev/null +++ b/packages/php-wasm/util/src/lib/smtp.test.ts @@ -0,0 +1,981 @@ +import { describe, it, expect } from 'vitest'; +import { + SmtpSink, + makeLoopbackPair, + parseMessage, + type CaughtMessage, + type SmtpSinkOptions, +} from './smtp'; + +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +/** + * Spins up a fake SMTP server (the "sink") connected to an in-memory + * client via a loopback byte stream. Returns helpers that let tests + * act as an SMTP client: send commands, read responses, and inspect + * captured emails. + * + * - `messages` collects every email the sink accepted. + * - The sink starts listening immediately. + */ +function createClient(opts?: SmtpSinkOptions) { + const [duplexClient, duplexServer] = makeLoopbackPair(); + const messages: CaughtMessage[] = []; + const sink = new SmtpSink( + duplexServer, + (m: CaughtMessage) => messages.push(m), + opts + ); + void sink.start(); + const writer = duplexClient.writable.getWriter(); + const reader = duplexClient.readable.getReader(); + + /** Read the next chunk the server sent (e.g. a response line). */ + async function read(): Promise { + const { value } = await reader.read(); + return value ? dec.decode(value) : ''; + } + + /** Send raw bytes to the server (no CRLF added). */ + async function write(s: string) { + await writer.write(enc.encode(s)); + } + + return { + read, + write, + messages, + sink, + }; +} + +/** Run a full EHLO handshake, consuming multi-line responses. */ +async function ehlo( + client: ReturnType, + hostname = 'localhost' +): Promise { + await client.write(`EHLO ${hostname}\r\n`); + const lines: string[] = []; + for (;;) { + const resp = await client.read(); + lines.push(resp); + if (/^250 /m.test(resp)) break; + if (!/^250-/m.test(resp)) + throw new Error(`Unexpected EHLO response: ${resp}`); + } + return lines; +} + +describe('SmtpSink – happy path', () => { + it('captures an email through a full SMTP transaction', async () => { + // Walks the canonical SMTP transaction from RFC 5321 §3.3: + // 220 greeting → HELO → MAIL FROM → RCPT TO → DATA (354) → + // body terminated by "." (250 queued) → QUIT (221). + const client = createClient(); + const greeting = await client.read(); + expect(greeting).toMatch(/^220 /); + + await client.write('HELO localhost\r\n'); + let helo = await client.read(); + while (/^250-/.test(helo)) helo = await client.read(); + expect(helo).toMatch(/^250 /); + + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + + await client.write('Subject: Test Email\r\n'); + await client.write('From: test@localhost\r\n'); + await client.write('To: test2@localhost\r\n'); + await client.write('\r\n'); + await client.write('This is the email body content.\r\n'); + await client.write('.\r\n'); + expect(await client.read()).toMatch(/^250 /); + + await client.write('QUIT\r\n'); + expect(await client.read()).toMatch(/^221 /); + + expect(client.messages).toHaveLength(1); + const msg = client.messages[0]; + expect(msg.subject).toBe('Test Email'); + expect(msg.from).toContain('test@localhost'); + expect(msg.to).toContain('test2@localhost'); + expect((msg.text ?? '').trim()).toBe('This is the email body content.'); + }); +}); + +describe('SmtpSink – EHLO', () => { + it('advertises SIZE and PIPELINING', async () => { + // RFC 1870 §3 (SIZE) and RFC 2920 §3 (PIPELINING) ESMTP keywords. + const client = createClient(); + await client.read(); + const lines = await ehlo(client); + const joined = lines.join('\n'); + expect(joined).toMatch(/SIZE/); + expect(joined).toMatch(/PIPELINING/); + }); + + it('advertises AUTH when mechs are configured', async () => { + // RFC 4954 §3: the AUTH EHLO keyword is advertised with a + // space-separated list of available SASL mechanism names as + // its parameter. + const client = createClient({ + auth: { mechs: ['PLAIN', 'LOGIN'] }, + }); + await client.read(); + const lines = await ehlo(client); + const joined = lines.join('\n'); + expect(joined).toMatch(/AUTH PLAIN LOGIN/); + }); + + it('does not advertise STARTTLS', async () => { + // The sink runs over an in-process loopback duplex with + // nothing to encrypt, so STARTTLS is never offered. Clients + // that need TLS must be configured for plain SMTP. + const client = createClient(); + await client.read(); + const lines = await ehlo(client); + const joined = lines.join('\n'); + expect(joined).not.toMatch(/STARTTLS/); + }); + + it('HELO returns a single-line greeting with no extension lines', async () => { + // RFC 5321 §4.1.1.1: HELO is the legacy non-extended greeting, + // so it must NOT advertise ESMTP extensions. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await client.write('HELO localhost\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^250 /); + expect(resp).not.toMatch(/^250-/m); + expect(resp).not.toMatch(/AUTH/); + expect(resp).not.toMatch(/SIZE/); + expect(resp).not.toMatch(/PIPELINING/); + }); + + it('rejects EHLO with no domain argument', async () => { + // RFC 5321 §4.1.1.1 ABNF: + // ehlo = "EHLO" SP ( Domain / address-literal ) CRLF + // The Domain (or address-literal) is a required production, + // so a bare "EHLO\r\n" is a syntax error. + const client = createClient(); + await client.read(); + await client.write('EHLO\r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('rejects HELO with no domain argument', async () => { + // RFC 5321 §4.1.1.1 ABNF: + // helo = "HELO" SP Domain CRLF + // Domain is mandatory; a bare "HELO\r\n" is a syntax error. + const client = createClient(); + await client.read(); + await client.write('HELO\r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('EHLO greeting line starts with the server domain, not free-form text', async () => { + // RFC 5321 §4.1.1.1 ABNF: + // ehlo-ok-rsp = "250-" Domain [ SP ehlo-greet ] CRLF + // *( "250-" ehlo-line CRLF ) + // "250" SP ehlo-line CRLF + // The first token after "250-" MUST be the server's Domain; + // any free-form `ehlo-greet` follows after a single SP. + const client = createClient(); + await client.read(); + await client.write('EHLO client.example.com\r\n'); + const first = await client.read(); + // The first reply line is "250-[ SP ehlo-greet]". + // The greeter is "localhost", matching the 220 banner. + expect(first).toMatch(/^250-localhost(\s|\r\n)/); + }); + + it('HELO greeting line starts with the server domain', async () => { + // RFC 5321 §4.1.1.1 ABNF for HELO uses the same single-line + // `"250" SP Domain [ SP ehlo-greet ]` shape. + const client = createClient(); + await client.read(); + await client.write('HELO client.example.com\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^250 localhost(\s|\r\n)/); + }); +}); + +describe('SmtpSink – STARTTLS', () => { + it('refuses STARTTLS with 502', async () => { + // RFC 5321 §4.2.4: an unimplemented command is answered with + // 502. The sink never advertises STARTTLS in EHLO, so a + // client that issues it anyway is treated as having sent an + // unrecognized command. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('STARTTLS\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^502 /); + }); +}); + +describe('SmtpSink – AUTH PLAIN', () => { + it('accepts valid credentials inline', async () => { + // RFC 4954 §4 (initial-response form) + RFC 4616 §2 (PLAIN + // SASL message: [authzid] UTF8NUL authcid UTF8NUL passwd). + // Success returns 235. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + // PLAIN: \0username\0password in base64 + const creds = btoa('\0user\0pass'); + await client.write(`AUTH PLAIN ${creds}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^235 /); + }); + + it('accepts valid credentials via challenge-response', async () => { + // RFC 4954 §4: when no initial response is supplied, the server + // issues "334 " with an empty challenge and the client follows + // up with the SASL response on its own line. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH PLAIN\r\n'); + const challenge = await client.read(); + expect(challenge).toMatch(/^334 /); + const creds = btoa('\0user\0pass'); + await client.write(`${creds}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^235 /); + }); + + it('rejects invalid credentials', async () => { + // RFC 4954 §6: bad credentials produce "535 5.7.8 + // Authentication credentials invalid". + const client = createClient({ + auth: { + mechs: ['PLAIN'], + validator: async (_mech, { username, password }) => + username === 'admin' && password === 'secret', + }, + }); + await client.read(); + await ehlo(client); + const creds = btoa('\0wrong\0creds'); + await client.write(`AUTH PLAIN ${creds}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^535 /); + }); + + it('allows cancellation with *', async () => { + // RFC 4954 §4: a single "*" sent in place of a SASL response + // cancels the AUTH exchange; the server returns 501. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH PLAIN\r\n'); + await client.read(); + await client.write('*\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^501 /); + }); +}); + +describe('SmtpSink – AUTH LOGIN', () => { + it('completes multi-step LOGIN flow', async () => { + // LOGIN SASL is non-standard (draft-murchison-sasl-login) but + // universally deployed: server prompts with base64("Username:") + // then base64("Password:") via 334 challenges, then 235 on + // success per RFC 4954 §4. + const client = createClient({ + auth: { mechs: ['LOGIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH LOGIN\r\n'); + const usernameChallenge = await client.read(); + expect(usernameChallenge).toMatch(/^334 /); + await client.write(`${btoa('myuser')}\r\n`); + const passwordChallenge = await client.read(); + expect(passwordChallenge).toMatch(/^334 /); + await client.write(`${btoa('mypass')}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^235 /); + }); + + it('accepts initial-response as username', async () => { + // RFC 4954 §4: clients may bundle the first SASL response onto + // the AUTH command line. For LOGIN that response is the + // username, so the server skips straight to the password + // challenge. + const client = createClient({ + auth: { mechs: ['LOGIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write(`AUTH LOGIN ${btoa('myuser')}\r\n`); + const passwordChallenge = await client.read(); + expect(passwordChallenge).toMatch(/^334 /); + await client.write(`${btoa('mypass')}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^235 /); + }); + + it('rejects invalid LOGIN credentials', async () => { + // RFC 4954 §6: failed authentication exchange returns 535. + const client = createClient({ + auth: { + mechs: ['LOGIN'], + validator: async () => false, + }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH LOGIN\r\n'); + await client.read(); + await client.write(`${btoa('user')}\r\n`); + await client.read(); + await client.write(`${btoa('wrong')}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^535 /); + }); +}); + +describe('SmtpSink – AUTH edge cases', () => { + it('rejects AUTH with no mechanism', async () => { + // RFC 4954 §4: AUTH command requires a mechanism argument; + // the server replies 501 on syntax errors. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^501 /); + }); + + it('rejects already-authenticated client', async () => { + // RFC 4954 §4: after a successful AUTH, further AUTH commands + // in the same session must be rejected with 503. + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + const creds = btoa('\0u\0p'); + await client.write(`AUTH PLAIN ${creds}\r\n`); + await client.read(); + await client.write(`AUTH PLAIN ${creds}\r\n`); + const resp = await client.read(); + expect(resp).toMatch(/^503 /); + }); + + it('rejects unrecognized auth mechanism', async () => { + // RFC 4954 §4: a SASL mechanism the server doesn't support + // produces "504 5.5.4 Unrecognized authentication type". + const client = createClient({ + auth: { mechs: ['PLAIN'] }, + }); + await client.read(); + await ehlo(client); + await client.write('AUTH CRAM-MD5\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^504 /); + }); + + it('rejects MAIL/RCPT when requireAuth and not authenticated', async () => { + // RFC 4954 §6: "530 5.7.0 Authentication required" SHOULD be + // returned by any command other than AUTH/EHLO/HELO/NOOP/RSET/ + // QUIT when server policy requires authentication and the + // session is not yet authenticated. + const client = createClient({ + auth: { mechs: ['PLAIN'], requireAuth: true }, + }); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + const mailResp = await client.read(); + expect(mailResp).toMatch(/^530 /); + await client.write('RCPT TO:\r\n'); + const rcptResp = await client.read(); + expect(rcptResp).toMatch(/^530 /); + }); +}); + +describe('SmtpSink – command edge cases', () => { + it('RSET clears the envelope', async () => { + // RFC 5321 §4.1.1.5: RSET aborts the current mail transaction + // and clears reverse-path / forward-paths / mail data buffers, + // then the server replies 250. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + await client.read(); + await client.write('RSET\r\n'); + const rsetResp = await client.read(); + expect(rsetResp).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + const rcptResp = await client.read(); + expect(rcptResp).toMatch(/^503 /); + }); + + it('mid-session EHLO clears the envelope (RFC 5321 §4.1.4)', async () => { + // Regression: previously EHLO only set state='idle' without + // clearing buffers, leaking mailFrom/rcpts across sessions. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + await client.read(); + await client.write('RCPT TO:\r\n'); + await client.read(); + await ehlo(client); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^503 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^503 /); + }); + + it('drops the connection with 500 when a command line exceeds 512 octets', async () => { + // RFC 5321 §4.5.3.1.4: "The maximum total length of a command + // line including the command word and the is 512 + // octets." Outside of DATA mode the sink must refuse an + // un-terminated tail that has already exceeded that limit + // instead of growing lineBuf without bound. + const client = createClient(); + await client.read(); + // 600 bytes of garbage with no CRLF — comfortably over 512 + // but under the 1000-octet text-line limit, proving the sink + // uses the *command* limit when not in DATA mode. + await client.write('A'.repeat(600)); + const resp = await client.read(); + expect(resp).toMatch(/^500 /); + await expect(client.read()).resolves.toBe(''); + }); + + it('drops the connection with 500 when a DATA text line exceeds 1000 octets', async () => { + // RFC 5321 §4.5.3.1.6: "The maximum total length of a text + // line including the is 1000 octets." Inside DATA mode + // the sink applies the larger text-line limit; an + // un-terminated 1500-byte tail crosses it and must be + // refused. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + await client.write('Subject: Big\r\n\r\n'); + // 1500 bytes of body with no CRLF — over the 1000-octet + // text-line limit. + await client.write('A'.repeat(1500)); + const resp = await client.read(); + expect(resp).toMatch(/^500 /); + await expect(client.read()).resolves.toBe(''); + }); + + it('accepts a command line just under the 512-octet limit', async () => { + // RFC 5321 §4.5.3.1.4 caps command lines at 512 octets + // *including the CRLF*, so any compliant command can carry + // up to 510 octets of payload. NOOP accepts an arbitrary + // trailing string per §4.1.1.9, which gives us a clean way + // to test the upper bound without invoking another command's + // argument validation. + const client = createClient(); + await client.read(); + await ehlo(client); + // "NOOP " (5) + 505 chars + "\r\n" (2) = 512 octets total. + await client.write('NOOP ' + 'A'.repeat(505) + '\r\n'); + expect(await client.read()).toMatch(/^250 /); + }); + + it('NOOP returns 250', async () => { + // RFC 5321 §4.1.1.9: NOOP has no effect on parameters or + // previously entered commands and always succeeds with 250. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('NOOP\r\n'); + expect(await client.read()).toMatch(/^250 /); + }); + + it('VRFY returns 252', async () => { + // RFC 5321 §3.5.3 / §4.1.1.6: a server that does not verify + // addresses but is willing to accept the message answers VRFY + // with "252 Cannot VRFY user, but will accept message". + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('VRFY user\r\n'); + expect(await client.read()).toMatch(/^252 /); + }); + + it('unknown command returns 500', async () => { + // RFC 5321 §4.2.4: an unrecognized command produces 500 + // "Syntax error, command unrecognized". + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('XYZZY\r\n'); + expect(await client.read()).toMatch(/^500 /); + }); + + it.each(['EXPN', 'HELP', 'TURN'])( + 'recognized but unimplemented command %s returns 502', + async (cmd) => { + // RFC 5321 §4.2.4: a recognized but unimplemented command + // produces 502 "Command not implemented". EXPN and HELP + // are optional per §4.1.1.7 / §4.1.1.8; TURN is the + // historical RFC 821 reverse-direction command, listed + // among RFC 821 features deprecated in RFC 5321 Appendix + // F.1. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write(`${cmd}\r\n`); + expect(await client.read()).toMatch(/^502 /); + } + ); + + it('rejects MAIL FROM with bad syntax', async () => { + // RFC 5321 §4.1.1.2: the MAIL command requires "FROM:" and a + // reverse-path; malformed input yields 501. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL TO:\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^501 /); + }); + + it('rejects RCPT TO with bad syntax', async () => { + // RFC 5321 §4.1.1.3: the RCPT command requires "TO:" and a + // forward-path; malformed input yields 501. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('RCPT FROM:\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^501 /); + }); + + it('rejects MAIL FROM with a space after the colon', async () => { + // RFC 5321 §3.3: "spaces are not permitted on either side of + // the colon following FROM in the MAIL command or TO in the + // RCPT command. The syntax is exactly as given above." This + // is called out explicitly because it has been "a common + // source of errors". + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM: \r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('rejects RCPT TO with a space after the colon', async () => { + // RFC 5321 §3.3: same no-space rule as MAIL FROM. The session + // must be in the mail state for the rejection to be a syntax + // error rather than a sequence error, so set up MAIL first. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO: \r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('rejects MAIL FROM without angle brackets', async () => { + // RFC 5321 §4.1.2 ABNF: Reverse-path is `Path / "<>"` and + // `Path = "<" [ A-d-l ":" ] Mailbox ">"`. The angle brackets + // are mandatory; a bare addr-spec is a syntax error. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:a@b.com\r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('rejects RCPT TO without angle brackets', async () => { + // RFC 5321 §4.1.2: Forward-path uses the same `Path` + // production as Reverse-path, so the brackets are required + // here too. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:c@d.com\r\n'); + expect(await client.read()).toMatch(/^501 /); + }); + + it('accepts MAIL FROM with trailing ESMTP parameters', async () => { + // RFC 5321 §4.1.1.2 ABNF: + // mail = "MAIL FROM:" Reverse-path + // [SP Mail-parameters] CRLF + // A single SP separates the closing `>` of the path from the + // optional ESMTP parameter list (e.g. RFC 1870 SIZE=N). The + // sink must accept the path and ignore the parameters. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM: SIZE=42\r\n'); + expect(await client.read()).toMatch(/^250 /); + }); + + it('rejects RCPT before MAIL', async () => { + // RFC 5321 §3.3 + §4.1.1.3: RCPT TO can only follow a MAIL + // FROM in the current transaction; otherwise the server + // answers 503 "Bad sequence of commands". + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('RCPT TO:\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^503 /); + }); + + it('rejects DATA before MAIL/RCPT', async () => { + // RFC 5321 §3.3 + §4.1.1.4: DATA requires at least one + // successful RCPT (which itself requires a MAIL FROM); else + // the server answers 503 "Bad sequence of commands". + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('DATA\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^503 /); + }); + + it('QUIT returns 221 and closes', async () => { + // RFC 5321 §4.1.1.10: the receiver MUST send "221 + // Service closing transmission channel" and then close the + // transmission channel. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('QUIT\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^221 /); + }); +}); + +describe('SmtpSink – data handling', () => { + it('handles dot-stuffing (lines starting with ..)', async () => { + // RFC 5321 §4.5.2 (transparency): a leading "." on a body line + // is doubled by the sender and stripped by the receiver so the + // end-of-data marker (a bare "." line) cannot be confused with + // content. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + await client.read(); + await client.write('RCPT TO:\r\n'); + await client.read(); + await client.write('DATA\r\n'); + await client.read(); + await client.write('Subject: Dots\r\n'); + await client.write('\r\n'); + // A line that starts with a dot must be dot-stuffed by the client + await client.write('..This line started with a dot.\r\n'); + await client.write('Normal line.\r\n'); + await client.write('.\r\n'); + await client.read(); // 250 Queued + + expect(client.messages).toHaveLength(1); + expect(client.messages[0].text).toContain( + '.This line started with a dot.' + ); + expect(client.messages[0].text).toContain('Normal line.'); + }); + + it('rejects message exceeding maxSize', async () => { + // RFC 1870 §6.3: when the message exceeds the SIZE the server + // declared, the server returns "552 Message size exceeds fixed + // maximum message size" after the end-of-data marker. + const client = createClient({ maxSize: 100 }); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + await client.read(); + await client.write('RCPT TO:\r\n'); + await client.read(); + await client.write('DATA\r\n'); + await client.read(); + await client.write('Subject: Big\r\n'); + await client.write('\r\n'); + await client.write('X'.repeat(200) + '\r\n'); + await client.write('.\r\n'); + const resp = await client.read(); + expect(resp).toMatch(/^552 /); + expect(client.messages).toHaveLength(0); + }); + + it('drains DATA after maxSize overflow and keeps session usable', async () => { + // Regression: issuing 552 before end-of-data flips out of + // dataMode, so remaining body lines are parsed as SMTP commands + // and the session is poisoned. RFC 1870 §6.3 requires the 552 + // to come *after* the end-of-data marker. + const client = createClient({ maxSize: 100 }); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:\r\n'); + await client.read(); + await client.write('RCPT TO:\r\n'); + await client.read(); + await client.write('DATA\r\n'); + await client.read(); + + let body = 'Subject: Big\r\n\r\n'; + for (let i = 0; i < 200; i++) body += `line${i}\r\n`; + body += '.\r\n'; + await client.write(body); + + expect(await client.read()).toMatch(/^552 /); + expect(client.messages).toHaveLength(0); + + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + await client.write('Subject: Small\r\n\r\nbody\r\n.\r\n'); + expect(await client.read()).toMatch(/^250 /); + + expect(client.messages).toHaveLength(1); + expect(client.messages[0].subject).toBe('Small'); + }); + + it('accepts the null reverse-path MAIL FROM:<> for bounce messages', async () => { + // RFC 5321 §4.5.5: bounce messages use a null reverse-path, + // `MAIL FROM:<>`. extractPath previously rejected `<>` and + // `mailFrom` was left in an undefined state that broke RCPT. + const client = createClient(); + await client.read(); + await ehlo(client); + await client.write('MAIL FROM:<>\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + await client.write('Subject: Bounce\r\n\r\nDelivery failed.\r\n.\r\n'); + expect(await client.read()).toMatch(/^250 /); + expect(client.messages).toHaveLength(1); + // Empty envelope-from is preserved (not the literal string "<>"). + // The parsed `from` falls back to the envelope value when no + // From: header is present, so it should be empty here. + expect(client.messages[0].from).toBe(''); + }); + + it('sends multiple emails in one session', async () => { + // RFC 5321 §3.3: a transaction starts with MAIL, accepts one + // or more RCPTs, and is committed by DATA followed by the + // end-of-data marker. A session may carry further transactions + // without re-issuing HELO/EHLO; the spec contemplates this + // when it lets a new MAIL command (or RSET) reset all state + // tables and buffers. + const client = createClient(); + await client.read(); + await ehlo(client); + + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + await client.write('Subject: First\r\n\r\nBody 1\r\n.\r\n'); + expect(await client.read()).toMatch(/^250 /); + + await client.write('MAIL FROM:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('RCPT TO:\r\n'); + expect(await client.read()).toMatch(/^250 /); + await client.write('DATA\r\n'); + expect(await client.read()).toMatch(/^354 /); + await client.write('Subject: Second\r\n\r\nBody 2\r\n.\r\n'); + expect(await client.read()).toMatch(/^250 /); + + expect(client.messages).toHaveLength(2); + expect(client.messages[0].subject).toBe('First'); + expect(client.messages[1].subject).toBe('Second'); + }); +}); + +describe('parseMessage', () => { + it('parses a simple text/plain email', () => { + // RFC 5322 §2.1: a message is header fields followed by an + // empty line followed by the body. RFC 2045 §5 defaults the + // Content-Type to text/plain when no header is present. + const raw = + 'Subject: Hello\r\n' + + 'From: a@b.com\r\n' + + 'To: c@d.com\r\n' + + '\r\n' + + 'Body text here.\r\n'; + const result = parseMessage(raw, 'fallback@x.com', ['fb@y.com']); + expect(result.subject).toBe('Hello'); + expect(result.from).toBe('a@b.com'); + expect(result.to).toBe('c@d.com'); + expect(result.text?.trim()).toBe('Body text here.'); + }); + + it('preserves all recipients from a multi-recipient To header', () => { + // RFC 5322 §3.4: an address-list is a comma-separated sequence + // of mailbox / group productions, mixing "Display Name " + // and bare addr-spec forms. + const raw = + 'From: sender@test.com\r\n' + + 'To: Foo Bar , bare@test.com, ' + + '"Quoted Name" \r\n' + + 'Subject: Multi recipient\r\n' + + '\r\n' + + 'Body.\r\n'; + const result = parseMessage(raw, '', []); + expect(result.to).toContain('foo@test.com'); + expect(result.to).toContain('bare@test.com'); + expect(result.to).toContain('quoted@test.com'); + }); + + it('uses fallback from/to when headers are missing', () => { + // RFC 5321 §2.3.1 (Mail Objects): a mail object has an + // envelope (MAIL FROM / RCPT TO) and a content with its own + // header section. When the header omits From:/To: we fall + // back to the envelope values supplied by the SMTP + // transaction. + const raw = 'Subject: No addrs\r\n\r\nBody.\r\n'; + const result = parseMessage(raw, 'env@from.com', [ + 'env@to1.com', + 'env@to2.com', + ]); + expect(result.from).toBe('env@from.com'); + expect(result.to).toBe('env@to1.com, env@to2.com'); + }); + + it('shows (no subject) when Subject header is missing', () => { + // RFC 5322 §3.6.5: Subject is an optional header field. No + // spec mandates a placeholder; "(no subject)" is the + // long-standing MUA convention. + const raw = 'From: a@b.com\r\n\r\nBody.\r\n'; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe('(no subject)'); + }); + + it('decodes RFC 2047 base64 encoded subject', () => { + // RFC 2047 §4.1: encoded-word with "B" encoding wraps base64 + // of the byte sequence in the named charset. + // "Test" in base64 + const encoded = '=?utf-8?B?VGVzdA==?='; + const raw = `Subject: ${encoded}\r\n\r\nBody.\r\n`; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe('Test'); + }); + + it('decodes RFC 2047 Q-encoded subject', () => { + // RFC 2047 §4.2: Q-encoding is a quoted-printable variant + // where "_" represents 0x20 (space) inside encoded-words. + // "Hello World" Q-encoded (underscore = space) + const encoded = '=?utf-8?Q?Hello_World?='; + const raw = `Subject: ${encoded}\r\n\r\nBody.\r\n`; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe('Hello World'); + }); + + it('decodes quoted-printable body', () => { + // RFC 2045 §6.7: quoted-printable encodes non-ASCII octets as + // "=XX" hex escapes; the receiver reverses the escape using + // the declared charset. + const raw = + 'Subject: QP\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'Content-Transfer-Encoding: quoted-printable\r\n' + + '\r\n' + + 'Hello =C3=A9 world\r\n'; + const result = parseMessage(raw, '', []); + expect(result.text).toContain('Hello é world'); + }); + + it('decodes base64 body', () => { + // RFC 2045 §6.8: base64 encodes arbitrary octet streams in a + // 65-character ASCII subset for transport over 7-bit channels. + const raw = + 'Subject: B64\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + 'Content-Transfer-Encoding: base64\r\n' + + '\r\n' + + btoa('Decoded body content') + + '\r\n'; + const result = parseMessage(raw, '', []); + expect(result.text?.trim()).toBe('Decoded body content'); + }); + + it('extracts text/plain from multipart email', () => { + // RFC 2046 §5.1: multipart bodies are split by a "--boundary" + // delimiter and terminated by "--boundary--". multipart/ + // alternative (§5.1.4) lets a sender supply the same content + // in several formats; receivers pick the best they can render. + const boundary = 'BOUNDARY123'; + const raw = + `Subject: Multi\r\n` + + `Content-Type: multipart/alternative; boundary="${boundary}"\r\n` + + `\r\n` + + `--${boundary}\r\n` + + `Content-Type: text/plain; charset=utf-8\r\n` + + `\r\n` + + `Plain text part.\r\n` + + `--${boundary}\r\n` + + `Content-Type: text/html; charset=utf-8\r\n` + + `\r\n` + + `

HTML part.

\r\n` + + `--${boundary}--\r\n`; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe('Multi'); + expect(result.text?.trim()).toBe('Plain text part.'); + }); + + it('handles folded headers', () => { + // RFC 5322 §2.2.3: a long header field may be split onto + // multiple lines by inserting CRLF before any WSP. The + // receiver "unfolds" by removing the CRLF before the WSP. + const raw = + 'Subject: This is a very long\r\n' + + ' subject line that was folded\r\n' + + 'From: a@b.com\r\n' + + '\r\n' + + 'Body.\r\n'; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe( + 'This is a very long subject line that was folded' + ); + }); + + it('handles email with no body', () => { + // RFC 5322 §2.1: a body is OPTIONAL; if present it is + // separated from the headers by a single empty line. With no + // separator the body is empty. + const raw = 'Subject: Empty\r\nFrom: a@b.com\r\n'; + const result = parseMessage(raw, '', []); + expect(result.subject).toBe('Empty'); + expect(result.text).toBe(''); + }); +}); diff --git a/packages/php-wasm/util/src/lib/smtp.ts b/packages/php-wasm/util/src/lib/smtp.ts new file mode 100644 index 00000000000..391e92e7878 --- /dev/null +++ b/packages/php-wasm/util/src/lib/smtp.ts @@ -0,0 +1,764 @@ +export type ByteDuplex = { + readable: ReadableStream; + writable: WritableStream; +}; + +export type CaughtMessage = { + receivedAt: string; + from: string; + to: string; + subject: string; + headers: Record; + text?: string; + raw: string; + rawSize: number; +}; + +type SaslMech = 'PLAIN' | 'LOGIN'; + +// Server identifier used in the 220 greeting and as the Domain +// token in the EHLO/HELO 250 response (RFC 5321 §4.1.1.1 ABNF +// requires the first token after "250"/"250-" to be a Domain). +const SERVER_NAME = 'localhost'; + +export type AuthValidator = ( + mech: SaslMech, + cred: { username: string; password: string } +) => boolean | Promise; + +export type SmtpSinkOptions = { + maxSize?: number; + auth?: { + mechs?: SaslMech[]; // which mechanisms to offer + advertise?: boolean; // show AUTH on EHLO + requireAuth?: boolean; // 530 before MAIL/RCPT unless authenticated + validator?: AuthValidator; // check creds; default accepts anything + }; +}; + +/** + * SMTP server sink that receives emails and invokes a callback for + * each fully-received message. + */ +export class SmtpSink { + private enc = new TextEncoder(); + private dec = new TextDecoder(); + private lineBuf = ''; + private dataMode = false; + private dataLines: string[] = []; + private dataBytes = 0; + private mailFrom: string | null = null; + private rcpts: string[] = []; + private writer: WritableStreamDefaultWriter; + private reader: ReadableStreamDefaultReader; + private closed = false; + private readonly maxSize: number; + + // sequencing so replies stay in order + private seq: Promise = Promise.resolve(); + + // AUTH policy + private authAdvertise: boolean; + private authMechs: SaslMech[]; + private authRequire: boolean; + private authValidator: AuthValidator; + private authenticated = false; + private authPending = false; + private authState: + | { mech: 'PLAIN'; stage: 'waitInitial' } + | { mech: 'LOGIN'; stage: 'username' | 'password'; username?: string } + | null = null; + + private onEmail: (m: CaughtMessage) => void; + + constructor( + duplex: ByteDuplex, + onEmail: (m: CaughtMessage) => void, + opts: SmtpSinkOptions = {} + ) { + this.onEmail = onEmail; + this.writer = duplex.writable.getWriter(); + this.reader = duplex.readable.getReader(); + this.maxSize = opts.maxSize ?? 10 * 1024 * 1024; + + this.authMechs = opts.auth?.mechs ?? []; + this.authAdvertise = opts.auth?.advertise ?? this.authMechs.length > 0; + this.authRequire = opts.auth?.requireAuth ?? false; + this.authValidator = opts.auth?.validator ?? (async () => true); + } + + async start(): Promise { + await this.reply(220, `${SERVER_NAME} ESMTP ready`); + for (;;) { + const r = await this.reader.read(); + if (r.done) break; + this.consumeChunk(r.value); + if (this.closed) break; + } + // Wait for all enqueued handlers to finish before closing + // the writer. When the client closes the connection, the + // reader gets done immediately, but enqueued handlers + // (like handleDataLine(".") which delivers the email) may + // still be pending in the promise chain. + await this.seq; + await this.close(); + } + + private async close(): Promise { + if (this.closed) return; + this.closed = true; + await this.writer.close(); + } + + private consumeChunk(chunk: Uint8Array) { + const text = this.dec.decode(chunk, { stream: true }); + this.lineBuf += text; + + for (;;) { + const idx = this.lineBuf.indexOf('\r\n'); + if (idx < 0) break; + const line = this.lineBuf.slice(0, idx); + this.lineBuf = this.lineBuf.slice(idx + 2); + + this.enqueue(async () => { + if (this.dataMode) { + await this.handleDataLine(line); + } else if (this.authPending) { + await this.handleAuthLine(line); + } else { + await this.handleCommand(line); + } + }); + if (this.closed) return; + } + + // RFC 5321 §4.5.3.1.4: command lines (incl. CRLF) ≤ 512 + // octets. §4.5.3.1.6: text lines (incl. CRLF) ≤ 1000 octets. + // If the un-terminated tail of lineBuf has already grown past + // the limit that applies to the current mode, the peer is + // malformed (or hostile); refuse it and drop the session + // rather than letting lineBuf grow without bound. + const maxLineLen = this.dataMode ? 1000 : 512; + if (this.lineBuf.length > maxLineLen) { + this.lineBuf = ''; + this.enqueue(async () => { + await this.reply(500, 'line too long'); + await this.close(); + }); + } + } + + private enqueue(fn: () => Promise) { + this.seq = this.seq.then(fn); + } + + private async handleCommand(rawLine: string) { + const line = rawLine.trimEnd(); + const sp = line.indexOf(' '); + const cmd = (sp < 0 ? line : line.slice(0, sp)).toUpperCase(); + const arg = sp < 0 ? '' : line.slice(sp + 1); + + switch (cmd) { + case 'EHLO': + case 'HELO': { + // RFC 5321 §4.1.1.1 ABNF: both `helo` and `ehlo` + // require a Domain (or address-literal for EHLO) + // argument; an empty argument is a syntax error. + if (!arg.trim()) { + await this.reply(501, `syntax: ${cmd} `); + break; + } + // RFC 5321 §4.1.4: a successful EHLO/HELO issued mid- + // session MUST clear all buffers and reset state exactly + // as RSET would. Auth state is preserved (RFC 4954 §4). + this.resetEnvelope(); + if (cmd === 'HELO') { + // RFC 5321 §4.1.1.1 ABNF: + // ehlo-ok-rsp = "250" SP Domain [ SP ehlo-greet ] + // HELO uses the same single-line form. The first + // token after the reply code MUST be the server's + // Domain, optionally followed by free-form text. + await this.reply(250, `${SERVER_NAME} Hello ${arg}`); + break; + } + // RFC 5321 §4.1.1.1 ABNF for the multi-line response: + // "250-" Domain [ SP ehlo-greet ] CRLF + // *( "250-" ehlo-line CRLF ) + // "250" SP ehlo-line CRLF + // The first line therefore starts with the server's + // Domain, never with free-form text. + const ext: string[] = []; + if (this.authAdvertise && this.authMechs.length) { + const list = this.authMechs.join(' '); + ext.push(`AUTH ${list}`, `AUTH=${list}`); + } + ext.push(`SIZE ${this.maxSize}`, 'PIPELINING'); + await this.replyMulti(250, [ + `${SERVER_NAME} Hello ${arg}`, + ...ext, + ]); + break; + } + + case 'STARTTLS': { + // The loopback duplex carries no real network traffic + // so there is nothing to encrypt. STARTTLS is never + // advertised in EHLO and is always refused with 502 + // "Command not implemented" if a client tries it + // anyway. Clients that need TLS should be configured + // for plain SMTP against this sink. + await this.reply(502, 'Command not implemented'); + break; + } + + case 'AUTH': { + const [mechRaw, initialRaw] = arg.split(/\s+/, 2); + const mech = (mechRaw || '').toUpperCase() as SaslMech; + + if (!mech) { + await this.reply( + 501, + 'syntax: AUTH mechanism [initial-response]' + ); + break; + } + if (this.authenticated) { + await this.reply(503, 'already authenticated'); + break; + } + + if (!this.authMechs.includes(mech)) { + await this.reply(504, 'Unrecognized authentication type'); + break; + } + + if (mech === 'PLAIN') { + const init = normalizeInitial(initialRaw); + if (init == null) { + this.authPending = true; + this.authState = { + mech: 'PLAIN', + stage: 'waitInitial', + }; + await this.reply(334, ''); // empty challenge + } else { + const ok = await this.handleAuthPlain(init); + await this.finishAuth(ok); + } + break; + } + + if (mech === 'LOGIN') { + const init = normalizeInitial(initialRaw); + if (init != null) { + // initial response is username + const username = b64DecodeText(init); + this.authPending = true; + this.authState = { + mech: 'LOGIN', + stage: 'password', + username, + }; + await this.reply(334, b64('Password:')); + } else { + this.authPending = true; + this.authState = { mech: 'LOGIN', stage: 'username' }; + await this.reply(334, b64('Username:')); + } + break; + } + break; + } + + case 'MAIL': { + if (this.authRequire && !this.authenticated) { + await this.reply(530, 'Authentication required'); + break; + } + // RFC 5321 §3.3 + §4.1.1.2: the syntax is exactly + // `MAIL FROM:`. §3.3 explicitly forbids + // "spaces on either side of the colon", and the + // reverse-path MUST be enclosed in angle brackets (or + // be the literal `<>` for the null reverse-path, + // §4.5.5). + const path = parseEnvelopeArg(arg, 'FROM'); + if (path === null) { + await this.reply(501, 'syntax: MAIL FROM:'); + break; + } + this.mailFrom = path; + this.rcpts = []; + await this.reply(250, 'OK'); + break; + } + + case 'RCPT': { + if (this.authRequire && !this.authenticated) { + await this.reply(530, 'Authentication required'); + break; + } + // RFC 5321 §3.3 + §4.1.1.3: the syntax is exactly + // `RCPT TO:`. Same no-space, mandatory- + // brackets rule as MAIL FROM. + const path = parseEnvelopeArg(arg, 'TO'); + if (path === null) { + await this.reply(501, 'syntax: RCPT TO:'); + break; + } + // Explicit null check (not falsy): an empty string is a + // valid null reverse-path (`MAIL FROM:<>`, RFC 5321 + // §4.5.5) and must not gate RCPT. + if (this.mailFrom === null) { + await this.reply(503, 'need MAIL FROM first'); + break; + } + this.rcpts.push(path); + await this.reply(250, 'Accepted'); + break; + } + + case 'DATA': { + if (this.mailFrom === null || this.rcpts.length === 0) { + await this.reply(503, 'need MAIL/RCPT first'); + break; + } + await this.reply(354, 'End data with .'); + this.dataMode = true; + this.dataLines = []; + this.dataBytes = 0; + break; + } + + case 'RSET': + this.resetEnvelope(); + await this.reply(250, 'OK'); + break; + + case 'NOOP': + await this.reply(250, 'OK'); + break; + + case 'VRFY': + await this.reply( + 252, + 'Cannot VRFY user, but will accept message' + ); + break; + + case 'QUIT': + await this.reply(221, 'Bye'); + await this.close(); + break; + + case 'EXPN': + case 'HELP': + case 'TURN': + await this.reply(502, 'Command not implemented'); + break; + + default: + await this.reply(500, 'command not recognized'); + break; + } + } + + private async handleDataLine(line: string) { + if (line === '.') { + this.dataMode = false; + if (this.dataBytes > this.maxSize) { + // RFC 1870 §6.3: when the size overflow is discovered + // mid-stream, the 552 reply must come *after* the + // end-of-data marker. Anything else desyncs the session. + await this.reply(552, 'message size exceeds fixed limit'); + this.resetEnvelope(); + return; + } + const raw = this.dataLines.join('\r\n') + '\r\n'; + const { headers, subject, text, from, to } = parseMessage( + raw, + this.mailFrom ?? '', + this.rcpts + ); + const message: CaughtMessage = { + receivedAt: new Date().toISOString(), + from, + to, + subject, + headers, + text, + raw, + rawSize: this.dataBytes, + }; + + this.onEmail(message); + + await this.reply(250, 'OK'); + this.resetEnvelope(); + return; + } + + const actual = line.startsWith('..') ? line.slice(1) : line; + this.dataBytes += this.enc.encode(actual).byteLength + 2; + if (this.dataBytes <= this.maxSize) { + this.dataLines.push(actual); + } + } + + private async handleAuthLine(line: string) { + if (!this.authState) { + this.authPending = false; + return; + } + if (line === '*') { + this.authPending = false; + this.authState = null; + await this.reply(501, 'Authentication canceled'); + return; + } + + if (this.authState.mech === 'PLAIN') { + const ok = await this.handleAuthPlain(line.trim()); + await this.finishAuth(ok); + return; + } + + if (this.authState.mech === 'LOGIN') { + if (this.authState.stage === 'username') { + const username = b64DecodeText(line.trim()); + this.authState = { mech: 'LOGIN', stage: 'password', username }; + await this.reply(334, b64('Password:')); + return; + } + if (this.authState.stage === 'password') { + const password = b64DecodeText(line.trim()); + const ok = await this.authValidator('LOGIN', { + username: this.authState.username || '', + password, + }); + await this.finishAuth(ok); + return; + } + } + } + + private async handleAuthPlain(initialB64: string): Promise { + let decoded = ''; + try { + decoded = atob(initialB64); + } catch { + return false; + } + // formats: authzid\0authcid\0passwd OR \0authcid\0passwd + const parts = decoded.split('\u0000'); + let username = ''; + let password = ''; + if (parts.length >= 3) { + username = parts[1] || ''; + password = parts[2] || ''; + } else if (parts.length === 2) { + username = parts[0] || ''; + password = parts[1] || ''; + } else { + return false; + } + return await this.authValidator('PLAIN', { username, password }); + } + + private async finishAuth(ok: boolean) { + this.authPending = false; + this.authState = null; + if (ok) { + this.authenticated = true; + await this.reply(235, 'Authentication succeeded'); + } else { + await this.reply(535, 'Authentication credentials invalid'); + } + } + + private resetEnvelope() { + this.mailFrom = null; + this.rcpts = []; + this.dataMode = false; + this.dataLines = []; + this.dataBytes = 0; + } + + private async reply(code: number, text: string) { + await this.writer.write(this.enc.encode(`${code} ${text}\r\n`)); + } + private async replyMulti(code: number, lines: string[]) { + for (let i = 0; i < lines.length - 1; i++) { + await this.writer.write(this.enc.encode(`${code}-${lines[i]}\r\n`)); + } + await this.writer.write( + this.enc.encode(`${code} ${lines[lines.length - 1]}\r\n`) + ); + } +} + +/** + * Parses the argument of a MAIL or RCPT command into the envelope + * path. Returns `null` if the syntax does not match RFC 5321. + * + * RFC 5321 §3.3 + §4.1.1.2/3 require: + * - the keyword (`FROM` or `TO`) is followed immediately by a + * colon, with NO whitespace on either side ("a common source + * of errors", §3.3) + * - the path is enclosed in angle brackets, or is the literal + * `<>` for the null reverse-path (§4.5.5) + * - any ESMTP Mail-parameters that follow MUST be separated + * from the closing `>` by a single space (RFC 1870, RFC 4954) + */ +export function parseEnvelopeArg( + arg: string, + keyword: 'FROM' | 'TO' +): string | null { + const prefix = `${keyword}:<`; + if (arg.length < prefix.length + 1) return null; + if (arg.slice(0, prefix.length).toUpperCase() !== prefix) { + return null; + } + const close = arg.indexOf('>', prefix.length); + if (close < 0) return null; + // Anything past the closing bracket must either be empty or + // begin with a single space introducing ESMTP parameters. + const tail = arg.slice(close + 1); + if (tail !== '' && !tail.startsWith(' ')) return null; + return arg.slice(prefix.length, close); +} + +/** + * Extracts email addresses from an RFC 5322 address list. + * Handles a mix of "Name " and bare "addr" entries. + */ +export function extractAddresses(value: string): string[] { + const out: string[] = []; + for (const part of value.split(',')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const angle = trimmed.match(/<([^>]+)>/); + if (angle) { + out.push(angle[1].trim()); + } else if (trimmed.includes('@')) { + out.push(trimmed); + } + } + return out; +} + +export function unfoldHeaders(hdr: string): string { + return hdr.replace(/\r\n([ \t]+)/g, ' '); +} +export function splitHeaderBody(raw: string): { + headerRaw: string; + bodyRaw: string; +} { + const idx = raw.indexOf('\r\n\r\n'); + if (idx < 0) return { headerRaw: raw, bodyRaw: '' }; + return { headerRaw: raw.slice(0, idx), bodyRaw: raw.slice(idx + 4) }; +} +export function parseHeaderLines(headerRaw: string): Record { + const out: Record = {}; + const unfolded = unfoldHeaders(headerRaw); + const lines = unfolded.split('\r\n'); + for (const line of lines) { + const i = line.indexOf(':'); + if (i <= 0) continue; + const name = line.slice(0, i).toLowerCase(); + const val = line.slice(i + 1).trim(); + out[name] = (out[name] ? out[name] + ', ' : '') + val; + } + return out; +} +function decodeRfc2047(s: string): string { + return s.replace( + /=\?([^?]+)\?([BbQq])\?([^?]+)\?=/g, + (_m, cs, enc, data) => { + const charset = String(cs); + const kind = String(enc).toUpperCase(); + let bytes: Uint8Array; + if (kind === 'B') { + const bin = atob(String(data)); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + bytes = arr; + } else { + let txt = String(data).replace(/_/g, ' '); + txt = txt.replace(/=([0-9A-Fa-f]{2})/g, (_m, h) => + String.fromCharCode(parseInt(h, 16)) + ); + bytes = new Uint8Array([...txt].map((c) => c.charCodeAt(0))); + } + try { + return new TextDecoder(normalizeCharset(charset)).decode(bytes); + } catch { + return new TextDecoder().decode(bytes); + } + } + ); +} +function normalizeCharset(cs: string): string { + cs = cs.toLowerCase(); + return cs === 'utf8' ? 'utf-8' : cs; +} +function qpDecodeToBytes(s: string): Uint8Array { + s = s.replace(/=\r\n/g, ''); + const out: number[] = []; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === '=' && i + 2 < s.length) { + const h = s.slice(i + 1, i + 3); + if (/^[0-9A-Fa-f]{2}$/.test(h)) { + out.push(parseInt(h, 16)); + i += 2; + continue; + } + } + out.push(ch.charCodeAt(0)); + } + return new Uint8Array(out); +} +function b64DecodeToBytes(s: string): Uint8Array { + const bin = atob(s.replace(/\s+/g, '')); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} +function b64DecodeText(s: string): string { + const bytes = b64DecodeToBytes(s); + return new TextDecoder().decode(bytes); +} +function b64(s: string): string { + return btoa(s); +} +function stripQuotes(s: string): string { + const m = s.match(/^"(.*)"$/); + return m ? m[1] : s; +} +function getParam(h: string, name: string): string | null { + const re = new RegExp(`;\\s*${name}=([^;]+)`, 'i'); + const m = h.match(re); + return m ? stripQuotes(m[1]) : null; +} +function pickTextPlainFromMultipart( + body: string, + boundary: string +): { headers: Record; content: string } | null { + const b = `--${boundary}`; + const end = `--${boundary}--`; + const lines = body.split('\r\n'); + let cur: string[] = []; + const parts: string[] = []; + let inPart = false; + for (const line of lines) { + if (line === b) { + if (inPart && cur.length) parts.push(cur.join('\r\n')); + inPart = true; + cur = []; + } else if (line === end) { + if (inPart && cur.length) parts.push(cur.join('\r\n')); + inPart = false; + break; + } else if (inPart) { + cur.push(line); + } + } + if (inPart && cur.length) parts.push(cur.join('\r\n')); + for (const p of parts) { + const { headerRaw, bodyRaw } = splitHeaderBody(p); + const ph = parseHeaderLines(headerRaw); + const ct = (ph['content-type'] || 'text/plain').toLowerCase(); + if (ct.startsWith('text/plain')) + return { headers: ph, content: bodyRaw }; + } + return null; +} +function decodeBody( + cte: string | undefined, + charset: string | undefined, + content: string +): string { + cte = (cte || '').toLowerCase(); + const cs = normalizeCharset(charset || 'utf-8'); + let bytes: Uint8Array; + if (cte === 'base64') bytes = b64DecodeToBytes(content); + else if (cte === 'quoted-printable') bytes = qpDecodeToBytes(content); + else bytes = new Uint8Array([...content].map((c) => c.charCodeAt(0))); + try { + return new TextDecoder(cs).decode(bytes); + } catch { + return new TextDecoder().decode(bytes); + } +} +export function parseMessage( + raw: string, + fallbackFrom: string, + fallbackRcpts: string[] +): { + headers: Record; + subject: string; + text?: string; + from: string; + to: string; +} { + const { headerRaw, bodyRaw } = splitHeaderBody(raw); + const headers = parseHeaderLines(headerRaw); + const subject = headers['subject'] + ? decodeRfc2047(headers['subject']) + : '(no subject)'; + const from = headers['from'] + ? decodeRfc2047(headers['from']) + : fallbackFrom; + + const recipientParts: string[] = []; + for (const hdr of ['to', 'cc', 'bcc']) { + if (headers[hdr]) { + recipientParts.push(decodeRfc2047(headers[hdr])); + } + } + const to = + recipientParts.length > 0 + ? recipientParts.join(', ') + : fallbackRcpts.join(', '); + + let text: string | undefined; + const ct = (headers['content-type'] || 'text/plain').toLowerCase(); + if (ct.startsWith('multipart/')) { + const boundary = getParam(headers['content-type'], 'boundary'); + if (boundary) { + const part = pickTextPlainFromMultipart(bodyRaw, boundary); + if (part) { + const pcte = ( + part.headers['content-transfer-encoding'] || '' + ).toLowerCase(); + const pcharset = + getParam(part.headers['content-type'] || '', 'charset') || + 'utf-8'; + text = decodeBody(pcte, pcharset, part.content); + } + } + } else if (ct.startsWith('text/plain')) { + const cte = (headers['content-transfer-encoding'] || '').toLowerCase(); + const charset = + getParam(headers['content-type'] || '', 'charset') || 'utf-8'; + text = decodeBody(cte, charset, bodyRaw); + } else { + text = bodyRaw; + } + return { headers, subject, text, from, to }; +} + +export function makeLoopbackPair(): [ByteDuplex, ByteDuplex] { + const a2b = new TransformStream(); + const b2a = new TransformStream(); + const a: ByteDuplex = { readable: b2a.readable, writable: a2b.writable }; + const b: ByteDuplex = { readable: a2b.readable, writable: b2a.writable }; + return [a, b]; +} + +function normalizeInitial(x?: string): string | null { + if (!x) return null; + const t = x.trim(); + if (t === '' || t === '=') return null; + return t; +} diff --git a/packages/php-wasm/util/src/lib/websocket-shim.ts b/packages/php-wasm/util/src/lib/websocket-shim.ts new file mode 100644 index 00000000000..bdc1fdb3e09 --- /dev/null +++ b/packages/php-wasm/util/src/lib/websocket-shim.ts @@ -0,0 +1,117 @@ +/** + * Minimal WebSocket-shaped class used to intercept Emscripten's + * WebSocket-based TCP connections without opening a real network socket. + * + * Emscripten's networking layer treats sockets as WebSockets: + * - In the browser it consumes the property-based API + * (`onmessage`, `onclose`, etc.) and `addEventListener`. + * - In Node.js (via the `ws` package) it consumes the EventEmitter-style + * API (`on('message', (data, isBinary) => ...)`). + * + * This shim implements both surfaces so subclasses can be returned from a + * `websocket.decorator` hook in either runtime. Subclasses override `send` + * and `close` to wire up outbound bytes, and call the + * `emitOpen` / `emitMessage` / `emitClose` / `emitError` helpers to deliver + * inbound events. Those helpers and the `listeners` map are public because + * `websocket.decorator` returns an anonymous subclass expression, and + * TypeScript's TS4094 forbids exported class expressions whose base has + * private or protected members. + */ +export class WebSocketShim { + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + + readyState = 0; // CONNECTING + + url: string; + protocol = ''; + binaryType: 'arraybuffer' | 'blob' = 'arraybuffer'; + extensions = ''; + bufferedAmount = 0; + + onopen: ((e: any) => void) | null = null; + onmessage: ((e: any) => void) | null = null; + onclose: ((e: any) => void) | null = null; + onerror: ((e: any) => void) | null = null; + + listeners = new Map void>>(); + + constructor(url = '') { + this.url = url; + } + + // Browser-style listener API + addEventListener(event: string, fn: (...args: any[]) => void) { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(fn); + } + + removeEventListener(event: string, fn: (...args: any[]) => void) { + this.listeners.get(event)?.delete(fn); + } + + // Node `ws`-style listener API + on(event: string, fn: (...args: any[]) => void) { + this.addEventListener(event, fn); + } + + once(event: string, fn: (...args: any[]) => void) { + const wrapped = (...args: any[]) => { + this.removeEventListener(event, wrapped); + fn(...args); + }; + this.addEventListener(event, wrapped); + } + + removeListener(event: string, fn: (...args: any[]) => void) { + this.removeEventListener(event, fn); + } + + emitOpen() { + this.readyState = this.OPEN; + this.onopen?.({}); + const set = this.listeners.get('open'); + if (set) for (const fn of set) fn({}); + } + + /** + * Delivers an inbound message to consumers. Browser Emscripten reads + * messages via the `onmessage` property and expects a MessageEvent-shaped + * `{ data }` object, while Node Emscripten registers via `on('message')` + * (the `ws` library convention) and expects `(data, isBinary)`. + */ + emitMessage(data: Uint8Array | ArrayBuffer | string) { + this.onmessage?.({ data }); + const set = this.listeners.get('message'); + if (!set) return; + const isBinary = typeof data !== 'string'; + for (const fn of set) { + fn(data, isBinary); + } + } + + emitClose() { + this.readyState = this.CLOSED; + this.onclose?.({}); + const set = this.listeners.get('close'); + if (set) for (const fn of set) fn({}); + } + + emitError(err: any) { + this.onerror?.(err); + const set = this.listeners.get('error'); + if (set) for (const fn of set) fn(err); + } + + // To be overridden by subclasses. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + send(_data: ArrayBuffer | Uint8Array | string) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + close(_code?: number, _reason?: string) {} +} diff --git a/packages/php-wasm/web/src/lib/load-runtime.ts b/packages/php-wasm/web/src/lib/load-runtime.ts index 4886e8c0bee..86c7e3b5d5a 100644 --- a/packages/php-wasm/web/src/lib/load-runtime.ts +++ b/packages/php-wasm/web/src/lib/load-runtime.ts @@ -7,6 +7,8 @@ import { loadPHPRuntime } from '@php-wasm/universal'; import { getPHPLoaderModule } from './get-php-loader-module'; import type { TCPOverFetchOptions } from './tcp-over-fetch-websocket'; import { tcpOverFetchWebsocket } from './tcp-over-fetch-websocket'; +import { withSMTPSink } from '@php-wasm/universal'; +import type { CaughtMessage } from '@php-wasm/util'; import { withIntl } from './extensions/intl/with-intl'; export interface LoaderOptions { @@ -14,6 +16,7 @@ export interface LoaderOptions { onPhpLoaderModuleLoaded?: (module: PHPLoaderModule) => void; tcpOverFetch?: TCPOverFetchOptions; withIntl?: boolean; + withSMTPSink?: { port: number; onEmail: (m: CaughtMessage) => void }; } /** @@ -75,6 +78,13 @@ export async function loadWebRuntime( ); } + if (loaderOptions.withSMTPSink) { + emscriptenOptions = withSMTPSink( + loaderOptions.withSMTPSink, + await emscriptenOptions + ); + } + if (loaderOptions.withIntl) { emscriptenOptions = withIntl(phpVersion, emscriptenOptions); } diff --git a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts index e026cba3f9b..f48d2df1c13 100644 --- a/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts +++ b/packages/php-wasm/web/src/lib/tcp-over-fetch-websocket.ts @@ -45,7 +45,7 @@ import { ContentTypes } from './tls/1_2/types'; import { fetchWithCorsProxy } from '@php-wasm/web-service-worker'; import { ChunkedDecoderStream } from './chunked-decoder'; import type { EmscriptenOptions } from '@php-wasm/universal'; -import { concatUint8Arrays } from '@php-wasm/util'; +import { concatUint8Arrays, WebSocketShim } from '@php-wasm/util'; export type TCPOverFetchOptions = { CAroot: GeneratedCertificate; @@ -98,19 +98,9 @@ export interface TCPOverFetchWebsocketOptions { corsProxyUrl?: string; } -export class TCPOverFetchWebsocket { - CONNECTING = 0; - OPEN = 1; - CLOSING = 2; - CLOSED = 3; - readyState = this.CONNECTING; - binaryType = 'blob'; - bufferedAmount = 0; - extensions = ''; - protocol = 'ws'; +export class TCPOverFetchWebsocket extends WebSocketShim { host = ''; port = 0; - listeners = new Map(); CAroot?: GeneratedCertificate; corsProxyUrl?: string; @@ -120,7 +110,6 @@ export class TCPOverFetchWebsocket { fetchInitiated = false; bufferedBytesFromClient: Uint8Array = new Uint8Array(0); - url: string; options: string[]; constructor( @@ -132,13 +121,13 @@ export class TCPOverFetchWebsocket { outputType = 'messages', }: TCPOverFetchWebsocketOptions = {} ) { - this.url = url; + super(url); + this.protocol = 'ws'; this.options = options; const wsUrl = new URL(url); this.host = wsUrl.searchParams.get('host')!; this.port = parseInt(wsUrl.searchParams.get('port')!, 10); - this.binaryType = 'arraybuffer'; this.corsProxyUrl = corsProxyUrl; this.CAroot = CAroot; @@ -151,13 +140,13 @@ export class TCPOverFetchWebsocket { * Emscripten expects the message event to be emitted * so let's emit it. */ - this.emit('message', { data: chunk }); + this.emitMessage(chunk); }, abort: () => { // We don't know what went wrong and the browser // won't tell us much either, so let's just pretend // the server is unreachable. - this.emit('error', new Error('ECONNREFUSED')); + this.emitError(new Error('ECONNREFUSED')); this.close(); }, close: () => { @@ -171,73 +160,14 @@ export class TCPOverFetchWebsocket { // via the 'error' event. }); } - this.readyState = this.OPEN; - this.emit('open'); + this.emitOpen(); } - on(eventName: string, callback: (e: any) => void) { - this.addEventListener(eventName, callback); - } - - once(eventName: string, callback: (e: any) => void) { - const wrapper = (e: any) => { - callback(e); - this.removeEventListener(eventName, wrapper); - }; - this.addEventListener(eventName, wrapper); - } - - addEventListener(eventName: string, callback: (e: any) => void) { - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, new Set()); - } - this.listeners.get(eventName).add(callback); - } - - removeListener(eventName: string, callback: (e: any) => void) { - this.removeEventListener(eventName, callback); - } - - removeEventListener(eventName: string, callback: (e: any) => void) { - const listeners = this.listeners.get(eventName); - if (listeners) { - listeners.delete(callback); - } - } - - emit(eventName: string, data: any = {}) { - if (eventName === 'message') { - this.onmessage(data); - } else if (eventName === 'close') { - this.onclose(data); - } else if (eventName === 'error') { - this.onerror(data); - } else if (eventName === 'open') { - this.onopen(data); - } - const listeners = this.listeners.get(eventName); - if (listeners) { - for (const listener of listeners) { - listener(data); - } - } - } - - // Default event handlers that can be overridden by the user - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onclose(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onerror(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onmessage(data: any) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onopen(data: any) {} - /** * Emscripten calls this method whenever the WASM module * writes bytes to the TCP socket. */ - send(data: ArrayBuffer) { + override send(data: ArrayBuffer | Uint8Array | string) { if ( this.readyState === this.CLOSING || this.readyState === this.CLOSED @@ -245,7 +175,14 @@ export class TCPOverFetchWebsocket { return; } - this.clientUpstreamWriter.write(new Uint8Array(data)); + const bytes = + typeof data === 'string' + ? new TextEncoder().encode(data) + : data instanceof ArrayBuffer + ? new Uint8Array(data) + : data; + + this.clientUpstreamWriter.write(bytes); if (this.fetchInitiated) { return; @@ -255,7 +192,7 @@ export class TCPOverFetchWebsocket { // what to do with the incoming bytes. this.bufferedBytesFromClient = concatUint8Arrays([ this.bufferedBytesFromClient, - new Uint8Array(data), + bytes, ]); switch (guessProtocol(this.port, this.bufferedBytesFromClient)) { case false: @@ -263,7 +200,7 @@ export class TCPOverFetchWebsocket { // let's wait for more. return; case 'other': - this.emit('error', new Error('Unsupported protocol')); + this.emitError(new Error('Unsupported protocol')); this.close(); break; case 'tls': @@ -393,7 +330,9 @@ export class TCPOverFetchWebsocket { } } - close() { + override close() { + if (this.readyState >= this.CLOSING) return; + this.readyState = this.CLOSING; /** * Workaround a PHP.wasm issue – if the WebSocket is * closed asynchronously after the last chunk is received, @@ -406,11 +345,8 @@ export class TCPOverFetchWebsocket { * Either way, sending an empty data chunk before closing * the WebSocket resolves the problem. */ - this.emit('message', { data: new Uint8Array(0) }); - - this.readyState = this.CLOSING; - this.emit('close'); - this.readyState = this.CLOSED; + this.emitMessage(new Uint8Array(0)); + this.emitClose(); } }