From 1f97cc482ca201685eafc0952ee1b3a7b5d33a92 Mon Sep 17 00:00:00 2001
From: Guillaume Loulier
Date: Mon, 27 Jul 2026 16:20:08 +0200
Subject: [PATCH 1/2] feat(platform): add Windows x86_64 PE32+
cross-compilation and close php compliance gaps
Windows x86_64 (PE32+) joins the target list: a MinGW-GNU-ABI backend with MSx64
calling-convention codegen, Win32 shims standing in for the Linux syscalls the
runtime issues, an import table, and a parity gate that runs the codegen suite
against the new target.
Auditing that target against php-src turned up defects that were never
Windows-specific, so they are fixed here as well. Each is verified against php 8.5.6
and locked by a fixture that fails without the fix.
Silent data loss and memory disclosure:
sprintf("%s", $s) copied its argument into a 128-byte stack buffer to
NUL-terminate it for snprintf, truncating every string past 127 bytes and cutting
short at any NUL byte php allows inside a string. %s is now rendered natively.
Any sprintf conversion wider than 127 characters copied adjacent stack memory into
the result, because snprintf reports the length it *would* have written. Comparing
lengths hides this; only a content hash shows it.
filemtime() and filesize() read the stat buffer without checking whether stat()
had written it, so a missing path returned whatever the stack held. Both now
report false, as php does.
array_splice($a, 1, -1) corrupted the array outright: the negative length passed
the upper clamp and became the capacity handed to the allocator.
php semantics that C's formatter does not share, all of which snprintf was deciding:
precision is ignored on %d/%u/%c and empties %x/%X/%o, the space flag has no
meaning, %b and the %'X pad character do not exist in C, and %N$ names an argument
without advancing the sequential counter.
Negative lengths, which four functions read as "select nothing" because -1 doubled
as an in-band "argument omitted" sentinel: substr, substr_replace, array_slice and
array_splice now read them as php does, and the sentinel is gone.
The temporary directory was the "/tmp" literal on every POSIX target, ignoring
TMPDIR, which macOS always sets. sys_get_temp_dir(), tmpfile() and tempnam() now
resolve it through one runtime helper, and tempnam() falls back to the system
directory instead of returning a path to a file it never created.
Also: tls:// and ssl:// opened in plaintext, random_int() truncated its range to 32
bits, fopen's append mode did not append, and SIGPIPE killed the process instead of
failing the write.
The first full CI run of this branch turned four more defects up, each visible on
exactly one platform and hidden on the others.
The zval pack/unpack bridge had stopped being compiled at all. Resolving the merge
that brought this runtime in dropped `mod zval;` and the ten `zval::emit_*` calls,
replacing them with the new win32 entries. Nothing warned: an undeclared module is
not dead code, it is absent, so the zero-warning build stayed clean while every
helper it defines left the runtime object and `__rt_zval_pack` went undefined at
link.
sprintf formatted php integers at 32 bits on x86_64. The mini format named a bare
conversion, so snprintf read half the value and sprintf("%u", -1) rendered
4294967295. The AArch64 arm has written the "ll" modifier since it was first
emitted; no fixture used a value above 2**32, so nothing caught it.
sprintf rendered oversized conversions from clobbered registers on linux-aarch64.
A result wider than the 128-byte scratch is rendered a second time, and the
argument registers are caller-saved. Apple's ABI passes every variadic argument on
the stack, so the existing stack copy carried the re-render on macOS and hid this
completely; AAPCS64 passes the first in x3, or d0 for a double, so Linux formatted
whatever the first call had left behind -- the right number of bytes, the wrong
ones.
random_int dropped its lower bound on windows: random_int(-100, -50) returned
values in [0, 50]. The x86_64 lowering held min in r9 across the sampler call, and
r9 is caller-saved in both SysV and MSx64, so that only worked by accident of which
registers the sampler touched. On Linux the entropy draw is a bare syscall, which
clobbers rcx and r11; on windows it is rewritten into a call reaching
BCryptGenRandom, which clobbers r9. The AArch64 arm already spilled it.
Five fixtures asserted POSIX rules php does not apply on windows -- TMPDIR against
GetTempPath, /var/tmp probe paths, and the eval constants, which passed there only
because eval answered with the host's values regardless of the target. Each now
names what php defines for its own platform. compiled_binary_size measured `test`
rather than `test.exe` and turned the missing file into a zero, reporting a link
that never happened as a bridge that was not linked.
The crypto-transport fixtures asserted two things at once -- that the handshake
completed, and that the encrypted stream then reads and writes correctly -- so a
failure could not say which half broke. A fixture now asserts only that
stream_socket_client() hands back a stream, which is the half php guarantees in
the transport factory, separating the two failure modes.
Two defects the fixtures could not have caught, found while instrumenting them.
The handshake stream_socket_client() runs on a crypto transport had no bound at
all. Against a peer that completes the TCP connect and then says nothing, rustls
waits on a read the descriptor has no timeout for, and the process blocks forever
-- php returns false after default_socket_timeout. A hung client costs far more
than a failing one: it takes a whole CI shard down with it. php gets this for free
because the stream carries the timeout and crypto is negotiated through the
stream; elephc attaches rustls to a raw descriptor, so it is set explicitly.
microtime(true) reported an instant unrelated to the clock on macos,
intermittently, and the fixture guarding it -- `$t > 1000000000` -- passed under
both faults. The AArch64 helper asked the Darwin trap to fill a timeval, which it
does not do: that is libsystem's job, and the trap returns 0, so the struct was
read back holding whatever the stack carried. Routing through libc, as the x86_64
arm already did, exposed the second fault: Darwin's suseconds_t is 32 bits, so a
64-bit load of tv_usec folded in padding libc does not clear, putting the result
4294.967296 seconds ahead whenever it held 1. The replacement fixture requires
agreement with time(), which samples the same clock.
A second fixture covers the write half of the encrypted path on its own, asserting
fwrite()'s byte count without reading back. With the connect half already
separated, the three data fixtures stop being one indivisible signal: whichever of
connect, write and read is at fault, exactly one fixture fails and names it.
The concat arena also had no capacity at all. It is a flat 64 KiB buffer with an
offset, shared by every string-producing helper, and both read paths wrote into it
without ever asking whether the bytes fit. fread($f, 200000) and
stream_get_contents() on anything past roughly 72 KiB ran straight off the end.
Nothing reported it: the returned string was correct, because it came back from
the overflowed region, while whatever followed the buffer was destroyed -- the
stream itself, in the observed cases, so fclose() rejected it as "unknown given"
where php closes it cleanly. Which neighbour dies depends on the program's layout,
which is what made it look intermittent.
A read that does not fit now takes its own heap block, and stream_get_contents
grows its accumulation onto the heap, doubling so a stream of n bytes costs O(n)
copying. Callers cannot tell: a heap-backed string is the same pointer/length
pair, and __rt_decref_any validates against the managed heap window, so it
releases those blocks and goes on ignoring arena slices. The arena cursor only
moves when the bytes actually landed in the arena.
---
.config/nextest.toml | 28 +
.github/workflows/ci.yml | 625 ++-
AGENTS.md | 45 +-
CHANGELOG.md | 5 +-
Cargo.lock | 124 +
README.md | 13 +-
ROADMAP.md | 2 +
crates/elephc-crypto/src/lib.rs | 25 +-
crates/elephc-image/src/cairo/context.rs | 8 +-
crates/elephc-image/src/cairo/surface.rs | 8 +-
crates/elephc-image/src/codec.rs | 8 +-
crates/elephc-image/src/draw.rs | 8 +-
crates/elephc-image/src/imagick.rs | 4 +-
crates/elephc-image/src/lib.rs | 105 +
crates/elephc-image/src/text.rs | 18 +-
crates/elephc-image/src/xfer.rs | 8 +-
crates/elephc-magician/Cargo.toml | 1 +
crates/elephc-magician/build.rs | 32 +-
crates/elephc-magician/src/context/core.rs | 25 +
.../src/context/runtime_state.rs | 311 ++
.../builtins/filesystem/basename.rs | 9 +-
.../interpreter/builtins/filesystem/chmod.rs | 17 +-
.../interpreter/builtins/filesystem/chown.rs | 34 +
.../interpreter/builtins/filesystem/copy.rs | 41 +-
.../builtins/filesystem/direct_dispatch.rs | 4 +
.../builtins/filesystem/dirname.rs | 11 +-
.../builtins/filesystem/disk_free_space.rs | 59 +-
.../builtins/filesystem/filetype.rs | 20 +-
.../interpreter/builtins/filesystem/lchgrp.rs | 1 +
.../interpreter/builtins/filesystem/lchown.rs | 1 +
.../builtins/filesystem/linkinfo.rs | 3 +
.../interpreter/builtins/filesystem/mod.rs | 7 +-
.../interpreter/builtins/filesystem/path.rs | 13 +
.../builtins/filesystem/proc_close.rs | 62 +
.../builtins/filesystem/proc_get_status.rs | 78 +
.../builtins/filesystem/proc_open.rs | 220 +
.../builtins/filesystem/proc_terminate.rs | 73 +
.../interpreter/builtins/filesystem/stat.rs | 186 +-
.../builtins/filesystem/sys_get_temp_dir.rs | 43 +-
.../interpreter/builtins/filesystem/umask.rs | 10 +-
.../interpreter/builtins/filesystem/unlink.rs | 9 +-
.../builtins/filesystem/user_wrapper_stat.rs | 31 +-
.../builtins/filesystem/values_dispatch.rs | 4 +
.../src/interpreter/builtins/hooks/direct.rs | 5 +
.../src/interpreter/builtins/hooks/values.rs | 5 +
.../src/interpreter/builtins/math/mod.rs | 2 +
.../interpreter/builtins/math/random_bytes.rs | 58 +
.../interpreter/builtins/math/random_int.rs | 10 +-
.../src/interpreter/builtins/mod.rs | 2 +-
.../builtins/network_env/gethostbyaddr.rs | 18 +-
.../builtins/network_env/gethostname.rs | 23 +-
.../builtins/network_env/getprotobyname.rs | 28 +-
.../builtins/network_env/getprotobynumber.rs | 6 +-
.../builtins/network_env/getservbyname.rs | 28 +-
.../builtins/network_env/getservbyport.rs | 7 +-
.../builtins/network_env/php_uname.rs | 45 +-
.../src/interpreter/builtins/random.rs | 43 +-
.../builtins/registry/tests/direct_hooks.rs | 1 +
.../builtins/string/escapeshellarg.rs | 16 +
.../builtins/string/escapeshellcmd.rs | 16 +
.../interpreter/builtins/string/mb_strlen.rs | 6 +
.../src/interpreter/builtins/string/mod.rs | 4 +
.../builtins/string/shell_escape.rs | 230 +
.../src/interpreter/builtins/time/date.rs | 87 +-
.../src/interpreter/builtins/time/hrtime.rs | 20 +-
.../interpreter/builtins/time/microtime.rs | 26 +-
.../src/interpreter/builtins/time/mktime.rs | 18 +
.../src/interpreter/builtins/time/mod.rs | 12 +-
.../src/interpreter/constant_eval.rs | 106 +-
.../src/interpreter/constants.rs | 10 +-
.../src/interpreter/expressions/calls.rs | 3 +
.../src/interpreter/libc_shims.rs | 454 +-
crates/elephc-magician/src/interpreter/mod.rs | 1 +
.../interpreter/tests/builtins_arrays_sets.rs | 12 +-
.../tests/builtins_filesystem_metadata.rs | 16 +-
.../tests/builtins_filesystem_ops.rs | 99 +-
.../tests/builtins_process_pipes.rs | 136 +
.../tests/builtins_strings_binary.rs | 20 +
.../tests/builtins_system_network.rs | 28 +-
.../elephc-magician/src/stream_resources.rs | 38 +-
.../stream_resources/file_process_opening.rs | 197 +-
.../src/stream_resources/operations.rs | 87 +-
.../src/stream_resources/sockets.rs | 25 +-
.../src/stream_resources/storage.rs | 10 +-
.../src/stream_resources/types.rs | 357 +-
crates/elephc-pdo/src/lib.rs | 173 +-
crates/elephc-pdo/src/my.rs | 6 +-
crates/elephc-pdo/src/sqlite.rs | 13 +-
crates/elephc-phar/Cargo.toml | 3 +
crates/elephc-phar/src/lib.rs | 128 +-
crates/elephc-tls/Cargo.toml | 6 +
crates/elephc-tls/src/lib.rs | 1563 +++++--
crates/elephc-tz/src/abi.rs | 209 +-
crates/elephc-tz/src/lib.rs | 71 +
crates/elephc-web/Cargo.toml | 8 +-
crates/elephc-web/src/lib.rs | 7 +-
crates/elephc-web/src/request_state.rs | 7 +
crates/elephc-web/src/server.rs | 91 +-
crates/elephc-web/src/session/file_io.rs | 303 +-
crates/elephc-web/src/session/state.rs | 5 +-
.../elephc-web/src/session/upload_progress.rs | 55 +-
crates/elephc-web/src/worker.rs | 105 +-
docs/README.md | 2 +-
docs/beyond-php/web.md | 48 +-
docs/compiling/cli-reference.md | 25 +-
docs/compiling/targets.md | 96 +-
docs/internals/architecture.md | 30 +-
.../_internal/__elephc_gmmktime_raw.md | 4 +-
.../builtins/_internal/__elephc_mktime_raw.md | 4 +-
.../_internal/__elephc_phar_bzip2_archive.md | 4 +-
.../__elephc_phar_decompress_archive.md | 4 +-
.../__elephc_phar_get_file_metadata.md | 4 +-
.../_internal/__elephc_phar_get_metadata.md | 4 +-
.../__elephc_phar_get_signature_hash.md | 4 +-
.../__elephc_phar_get_signature_type.md | 4 +-
.../_internal/__elephc_phar_get_stub.md | 4 +-
.../_internal/__elephc_phar_gzip_archive.md | 4 +-
.../_internal/__elephc_phar_list_entries.md | 4 +-
.../__elephc_phar_set_compression.md | 4 +-
.../__elephc_phar_set_file_metadata.md | 4 +-
.../_internal/__elephc_phar_set_metadata.md | 4 +-
.../_internal/__elephc_phar_set_stub.md | 4 +-
.../__elephc_phar_set_zip_password.md | 4 +-
.../_internal/__elephc_phar_sign_hash.md | 6 +-
.../_internal/__elephc_phar_sign_openssl.md | 4 +-
.../_internal/__elephc_ptr_is_null.md | 4 +-
.../_internal/__elephc_ptr_read_string.md | 4 +-
.../_internal/__elephc_ptr_write_string.md | 4 +-
.../_internal/__elephc_strtotime_raw.md | 6 +-
docs/internals/builtins/array/array_all.md | 2 +-
docs/internals/builtins/array/array_any.md | 2 +-
docs/internals/builtins/array/array_chunk.md | 2 +-
docs/internals/builtins/array/array_column.md | 2 +-
.../internals/builtins/array/array_combine.md | 2 +-
docs/internals/builtins/array/array_diff.md | 2 +-
.../builtins/array/array_diff_assoc.md | 2 +-
.../builtins/array/array_diff_key.md | 2 +-
docs/internals/builtins/array/array_fill.md | 2 +-
.../builtins/array/array_fill_keys.md | 2 +-
docs/internals/builtins/array/array_filter.md | 2 +-
docs/internals/builtins/array/array_find.md | 2 +-
docs/internals/builtins/array/array_flip.md | 2 +-
.../builtins/array/array_intersect.md | 2 +-
.../builtins/array/array_intersect_assoc.md | 2 +-
.../builtins/array/array_intersect_key.md | 2 +-
.../internals/builtins/array/array_is_list.md | 2 +-
.../builtins/array/array_key_exists.md | 2 +-
.../builtins/array/array_key_first.md | 2 +-
.../builtins/array/array_key_last.md | 2 +-
docs/internals/builtins/array/array_keys.md | 2 +-
docs/internals/builtins/array/array_map.md | 2 +-
docs/internals/builtins/array/array_merge.md | 2 +-
.../builtins/array/array_merge_recursive.md | 2 +-
.../builtins/array/array_multisort.md | 4 +-
docs/internals/builtins/array/array_pad.md | 2 +-
docs/internals/builtins/array/array_pop.md | 4 +-
.../internals/builtins/array/array_product.md | 2 +-
docs/internals/builtins/array/array_push.md | 4 +-
docs/internals/builtins/array/array_rand.md | 2 +-
docs/internals/builtins/array/array_reduce.md | 2 +-
.../internals/builtins/array/array_replace.md | 2 +-
.../builtins/array/array_replace_recursive.md | 2 +-
.../internals/builtins/array/array_reverse.md | 2 +-
docs/internals/builtins/array/array_search.md | 2 +-
docs/internals/builtins/array/array_shift.md | 4 +-
docs/internals/builtins/array/array_slice.md | 2 +-
docs/internals/builtins/array/array_splice.md | 4 +-
docs/internals/builtins/array/array_sum.md | 2 +-
docs/internals/builtins/array/array_udiff.md | 2 +-
.../builtins/array/array_uintersect.md | 2 +-
docs/internals/builtins/array/array_unique.md | 2 +-
.../internals/builtins/array/array_unshift.md | 4 +-
docs/internals/builtins/array/array_values.md | 2 +-
docs/internals/builtins/array/array_walk.md | 4 +-
.../builtins/array/array_walk_recursive.md | 4 +-
docs/internals/builtins/array/arsort.md | 4 +-
docs/internals/builtins/array/asort.md | 4 +-
.../builtins/array/call_user_func.md | 2 +-
.../builtins/array/call_user_func_array.md | 2 +-
docs/internals/builtins/array/count.md | 2 +-
docs/internals/builtins/array/in_array.md | 2 +-
docs/internals/builtins/array/krsort.md | 4 +-
docs/internals/builtins/array/ksort.md | 4 +-
docs/internals/builtins/array/natcasesort.md | 4 +-
docs/internals/builtins/array/natsort.md | 4 +-
docs/internals/builtins/array/range.md | 2 +-
docs/internals/builtins/array/rsort.md | 4 +-
docs/internals/builtins/array/shuffle.md | 4 +-
docs/internals/builtins/array/sort.md | 4 +-
docs/internals/builtins/array/uasort.md | 4 +-
docs/internals/builtins/array/uksort.md | 4 +-
docs/internals/builtins/array/usort.md | 4 +-
docs/internals/builtins/buffer/buffer_free.md | 2 +-
docs/internals/builtins/buffer/buffer_len.md | 2 +-
docs/internals/builtins/class/class_alias.md | 2 +-
.../builtins/class/class_attribute_args.md | 2 +-
.../builtins/class/class_attribute_names.md | 2 +-
docs/internals/builtins/class/class_exists.md | 2 +-
.../builtins/class/class_get_attributes.md | 2 +-
.../builtins/class/class_implements.md | 2 +-
.../internals/builtins/class/class_parents.md | 2 +-
docs/internals/builtins/class/class_uses.md | 2 +-
docs/internals/builtins/class/enum_exists.md | 2 +-
.../builtins/class/function_exists.md | 2 +-
docs/internals/builtins/class/get_class.md | 2 +-
.../builtins/class/get_declared_classes.md | 2 +-
.../builtins/class/get_declared_interfaces.md | 2 +-
.../builtins/class/get_declared_traits.md | 2 +-
.../builtins/class/get_parent_class.md | 2 +-
.../builtins/class/interface_exists.md | 2 +-
docs/internals/builtins/class/is_a.md | 2 +-
.../builtins/class/is_subclass_of.md | 2 +-
.../internals/builtins/class/method_exists.md | 2 +-
.../builtins/class/property_exists.md | 2 +-
docs/internals/builtins/class/trait_exists.md | 2 +-
docs/internals/builtins/date/checkdate.md | 2 +-
docs/internals/builtins/date/date.md | 4 +-
.../date/date_default_timezone_get.md | 2 +-
.../date/date_default_timezone_set.md | 2 +-
docs/internals/builtins/date/getdate.md | 2 +-
docs/internals/builtins/date/gmdate.md | 4 +-
docs/internals/builtins/date/gmmktime.md | 4 +-
docs/internals/builtins/date/hrtime.md | 2 +-
docs/internals/builtins/date/localtime.md | 2 +-
docs/internals/builtins/date/microtime.md | 2 +-
docs/internals/builtins/date/mktime.md | 4 +-
docs/internals/builtins/date/strtotime.md | 4 +-
docs/internals/builtins/date/time.md | 2 +-
.../internals/builtins/filesystem/basename.md | 2 +-
docs/internals/builtins/filesystem/chdir.md | 2 +-
docs/internals/builtins/filesystem/chgrp.md | 4 +-
docs/internals/builtins/filesystem/chmod.md | 2 +-
docs/internals/builtins/filesystem/chown.md | 4 +-
.../builtins/filesystem/clearstatcache.md | 2 +-
docs/internals/builtins/filesystem/copy.md | 2 +-
docs/internals/builtins/filesystem/dirname.md | 2 +-
.../builtins/filesystem/disk_free_space.md | 2 +-
.../builtins/filesystem/disk_total_space.md | 2 +-
.../builtins/filesystem/file_exists.md | 2 +-
.../builtins/filesystem/fileatime.md | 2 +-
.../builtins/filesystem/filectime.md | 2 +-
.../builtins/filesystem/filegroup.md | 2 +-
.../builtins/filesystem/fileinode.md | 2 +-
.../builtins/filesystem/filemtime.md | 4 +-
.../builtins/filesystem/fileowner.md | 2 +-
.../builtins/filesystem/fileperms.md | 2 +-
.../internals/builtins/filesystem/filesize.md | 4 +-
.../internals/builtins/filesystem/filetype.md | 2 +-
docs/internals/builtins/filesystem/fnmatch.md | 2 +-
docs/internals/builtins/filesystem/getcwd.md | 2 +-
docs/internals/builtins/filesystem/getenv.md | 2 +-
docs/internals/builtins/filesystem/glob.md | 2 +-
docs/internals/builtins/filesystem/is_dir.md | 2 +-
.../builtins/filesystem/is_executable.md | 2 +-
docs/internals/builtins/filesystem/is_file.md | 2 +-
docs/internals/builtins/filesystem/is_link.md | 2 +-
.../builtins/filesystem/is_readable.md | 2 +-
.../builtins/filesystem/is_writable.md | 2 +-
.../builtins/filesystem/is_writeable.md | 2 +-
docs/internals/builtins/filesystem/lchgrp.md | 4 +-
docs/internals/builtins/filesystem/lchown.md | 4 +-
docs/internals/builtins/filesystem/link.md | 2 +-
.../internals/builtins/filesystem/linkinfo.md | 2 +-
docs/internals/builtins/filesystem/lstat.md | 2 +-
docs/internals/builtins/filesystem/mkdir.md | 2 +-
.../internals/builtins/filesystem/pathinfo.md | 2 +-
docs/internals/builtins/filesystem/putenv.md | 2 +-
.../internals/builtins/filesystem/readfile.md | 2 +-
.../internals/builtins/filesystem/readlink.md | 2 +-
.../internals/builtins/filesystem/realpath.md | 2 +-
.../builtins/filesystem/realpath_cache_get.md | 2 +-
.../filesystem/realpath_cache_size.md | 2 +-
docs/internals/builtins/filesystem/rename.md | 2 +-
docs/internals/builtins/filesystem/rmdir.md | 2 +-
docs/internals/builtins/filesystem/scandir.md | 2 +-
docs/internals/builtins/filesystem/stat.md | 2 +-
docs/internals/builtins/filesystem/symlink.md | 2 +-
.../builtins/filesystem/sys_get_temp_dir.md | 2 +-
docs/internals/builtins/filesystem/tempnam.md | 8 +-
docs/internals/builtins/filesystem/tmpfile.md | 2 +-
docs/internals/builtins/filesystem/touch.md | 2 +-
docs/internals/builtins/filesystem/umask.md | 2 +-
docs/internals/builtins/filesystem/unlink.md | 2 +-
docs/internals/builtins/io/closedir.md | 2 +-
docs/internals/builtins/io/fclose.md | 2 +-
docs/internals/builtins/io/fdatasync.md | 2 +-
docs/internals/builtins/io/feof.md | 2 +-
docs/internals/builtins/io/fflush.md | 2 +-
docs/internals/builtins/io/fgetc.md | 2 +-
docs/internals/builtins/io/fgetcsv.md | 2 +-
docs/internals/builtins/io/fgets.md | 2 +-
docs/internals/builtins/io/file.md | 2 +-
.../builtins/io/file_get_contents.md | 2 +-
.../builtins/io/file_put_contents.md | 2 +-
docs/internals/builtins/io/flock.md | 4 +-
docs/internals/builtins/io/fopen.md | 2 +-
docs/internals/builtins/io/fpassthru.md | 2 +-
docs/internals/builtins/io/fprintf.md | 2 +-
docs/internals/builtins/io/fputcsv.md | 2 +-
docs/internals/builtins/io/fread.md | 2 +-
docs/internals/builtins/io/fscanf.md | 2 +-
docs/internals/builtins/io/fseek.md | 2 +-
docs/internals/builtins/io/fstat.md | 2 +-
docs/internals/builtins/io/fsync.md | 2 +-
docs/internals/builtins/io/ftell.md | 2 +-
docs/internals/builtins/io/ftruncate.md | 2 +-
docs/internals/builtins/io/fwrite.md | 2 +-
docs/internals/builtins/io/gethostbyaddr.md | 2 +-
docs/internals/builtins/io/gethostbyname.md | 2 +-
docs/internals/builtins/io/gethostname.md | 2 +-
docs/internals/builtins/io/getprotobyname.md | 2 +-
.../internals/builtins/io/getprotobynumber.md | 2 +-
docs/internals/builtins/io/getservbyname.md | 2 +-
docs/internals/builtins/io/getservbyport.md | 2 +-
docs/internals/builtins/io/hash_file.md | 2 +-
docs/internals/builtins/io/ob_clean.md | 2 +-
docs/internals/builtins/io/ob_end_clean.md | 2 +-
docs/internals/builtins/io/ob_end_flush.md | 2 +-
docs/internals/builtins/io/ob_flush.md | 2 +-
docs/internals/builtins/io/ob_get_clean.md | 2 +-
docs/internals/builtins/io/ob_get_contents.md | 2 +-
docs/internals/builtins/io/ob_get_flush.md | 2 +-
docs/internals/builtins/io/ob_get_length.md | 2 +-
docs/internals/builtins/io/ob_get_level.md | 2 +-
docs/internals/builtins/io/ob_get_status.md | 2 +-
.../builtins/io/ob_implicit_flush.md | 2 +-
.../internals/builtins/io/ob_list_handlers.md | 2 +-
docs/internals/builtins/io/ob_start.md | 2 +-
docs/internals/builtins/io/opendir.md | 2 +-
docs/internals/builtins/io/readdir.md | 2 +-
docs/internals/builtins/io/rewind.md | 2 +-
docs/internals/builtins/io/rewinddir.md | 2 +-
.../io/stream_bucket_make_writeable.md | 2 +-
.../builtins/io/stream_bucket_new.md | 2 +-
.../builtins/io/stream_context_create.md | 2 +-
.../builtins/io/stream_context_get_default.md | 2 +-
.../builtins/io/stream_context_get_options.md | 2 +-
.../builtins/io/stream_context_get_params.md | 2 +-
.../builtins/io/stream_context_set_default.md | 2 +-
.../builtins/io/stream_context_set_option.md | 2 +-
.../builtins/io/stream_context_set_params.md | 2 +-
.../builtins/io/stream_copy_to_stream.md | 2 +-
.../builtins/io/stream_filter_register.md | 2 +-
.../builtins/io/stream_filter_remove.md | 2 +-
.../builtins/io/stream_get_contents.md | 2 +-
.../builtins/io/stream_get_filters.md | 2 +-
docs/internals/builtins/io/stream_get_line.md | 2 +-
.../builtins/io/stream_get_meta_data.md | 2 +-
.../builtins/io/stream_get_transports.md | 2 +-
.../builtins/io/stream_get_wrappers.md | 2 +-
docs/internals/builtins/io/stream_is_local.md | 2 +-
docs/internals/builtins/io/stream_isatty.md | 2 +-
.../io/stream_resolve_include_path.md | 2 +-
docs/internals/builtins/io/stream_select.md | 4 +-
.../builtins/io/stream_set_blocking.md | 2 +-
.../builtins/io/stream_set_chunk_size.md | 2 +-
.../builtins/io/stream_set_read_buffer.md | 2 +-
.../builtins/io/stream_set_timeout.md | 2 +-
.../builtins/io/stream_set_write_buffer.md | 2 +-
.../builtins/io/stream_socket_accept.md | 4 +-
.../builtins/io/stream_socket_client.md | 4 +-
.../io/stream_socket_enable_crypto.md | 4 +-
.../builtins/io/stream_socket_get_name.md | 2 +-
.../builtins/io/stream_socket_pair.md | 2 +-
.../builtins/io/stream_socket_recvfrom.md | 4 +-
.../builtins/io/stream_socket_sendto.md | 2 +-
.../builtins/io/stream_socket_server.md | 2 +-
.../builtins/io/stream_socket_shutdown.md | 2 +-
.../builtins/io/stream_supports_lock.md | 2 +-
.../builtins/io/stream_wrapper_register.md | 2 +-
.../builtins/io/stream_wrapper_restore.md | 2 +-
.../builtins/io/stream_wrapper_unregister.md | 2 +-
docs/internals/builtins/io/vfprintf.md | 2 +-
docs/internals/builtins/json/json_decode.md | 2 +-
docs/internals/builtins/json/json_encode.md | 2 +-
.../builtins/json/json_last_error.md | 2 +-
.../builtins/json/json_last_error_msg.md | 2 +-
docs/internals/builtins/json/json_validate.md | 2 +-
docs/internals/builtins/math/abs.md | 2 +-
docs/internals/builtins/math/acos.md | 2 +-
docs/internals/builtins/math/asin.md | 2 +-
docs/internals/builtins/math/atan.md | 2 +-
docs/internals/builtins/math/atan2.md | 2 +-
docs/internals/builtins/math/ceil.md | 2 +-
docs/internals/builtins/math/clamp.md | 2 +-
docs/internals/builtins/math/cos.md | 2 +-
docs/internals/builtins/math/cosh.md | 2 +-
docs/internals/builtins/math/deg2rad.md | 2 +-
docs/internals/builtins/math/exp.md | 2 +-
docs/internals/builtins/math/fdiv.md | 2 +-
docs/internals/builtins/math/floor.md | 2 +-
docs/internals/builtins/math/fmod.md | 2 +-
docs/internals/builtins/math/hypot.md | 2 +-
docs/internals/builtins/math/intdiv.md | 2 +-
docs/internals/builtins/math/is_finite.md | 2 +-
docs/internals/builtins/math/is_infinite.md | 2 +-
docs/internals/builtins/math/is_nan.md | 2 +-
docs/internals/builtins/math/log.md | 2 +-
docs/internals/builtins/math/log10.md | 2 +-
docs/internals/builtins/math/log2.md | 2 +-
docs/internals/builtins/math/max.md | 2 +-
docs/internals/builtins/math/min.md | 2 +-
docs/internals/builtins/math/mt_rand.md | 2 +-
docs/internals/builtins/math/pi.md | 2 +-
docs/internals/builtins/math/pow.md | 2 +-
docs/internals/builtins/math/rad2deg.md | 2 +-
docs/internals/builtins/math/rand.md | 2 +-
docs/internals/builtins/math/random_bytes.md | 56 +
docs/internals/builtins/math/random_int.md | 4 +-
docs/internals/builtins/math/round.md | 4 +-
docs/internals/builtins/math/sin.md | 4 +-
docs/internals/builtins/math/sinh.md | 4 +-
docs/internals/builtins/math/sqrt.md | 4 +-
docs/internals/builtins/math/tan.md | 4 +-
docs/internals/builtins/math/tanh.md | 4 +-
docs/internals/builtins/misc/buffer_new.md | 2 +-
docs/internals/builtins/misc/define.md | 4 +-
docs/internals/builtins/misc/defined.md | 4 +-
docs/internals/builtins/misc/empty.md | 4 +-
docs/internals/builtins/misc/header.md | 4 +-
.../builtins/misc/http_response_code.md | 4 +-
docs/internals/builtins/misc/isset.md | 2 +-
docs/internals/builtins/misc/php_uname.md | 4 +-
docs/internals/builtins/misc/phpversion.md | 4 +-
docs/internals/builtins/misc/print_r.md | 4 +-
docs/internals/builtins/misc/serialize.md | 4 +-
docs/internals/builtins/misc/unserialize.md | 4 +-
docs/internals/builtins/misc/unset.md | 2 +-
docs/internals/builtins/misc/var_dump.md | 4 +-
docs/internals/builtins/pointer/ptr.md | 4 +-
docs/internals/builtins/pointer/ptr_get.md | 4 +-
.../internals/builtins/pointer/ptr_is_null.md | 4 +-
docs/internals/builtins/pointer/ptr_null.md | 4 +-
docs/internals/builtins/pointer/ptr_offset.md | 4 +-
docs/internals/builtins/pointer/ptr_read16.md | 4 +-
docs/internals/builtins/pointer/ptr_read32.md | 4 +-
docs/internals/builtins/pointer/ptr_read8.md | 4 +-
.../builtins/pointer/ptr_read_string.md | 4 +-
docs/internals/builtins/pointer/ptr_set.md | 4 +-
docs/internals/builtins/pointer/ptr_sizeof.md | 4 +-
.../internals/builtins/pointer/ptr_write16.md | 4 +-
.../internals/builtins/pointer/ptr_write32.md | 4 +-
docs/internals/builtins/pointer/ptr_write8.md | 4 +-
.../builtins/pointer/ptr_write_string.md | 4 +-
docs/internals/builtins/pointer/zval_free.md | 4 +-
docs/internals/builtins/pointer/zval_pack.md | 4 +-
docs/internals/builtins/pointer/zval_type.md | 4 +-
.../internals/builtins/pointer/zval_unpack.md | 4 +-
docs/internals/builtins/process/die.md | 2 +-
docs/internals/builtins/process/exec.md | 4 +-
docs/internals/builtins/process/exit.md | 2 +-
docs/internals/builtins/process/passthru.md | 4 +-
docs/internals/builtins/process/pclose.md | 4 +-
docs/internals/builtins/process/popen.md | 4 +-
docs/internals/builtins/process/proc_close.md | 56 +
.../builtins/process/proc_get_status.md | 56 +
docs/internals/builtins/process/proc_open.md | 58 +
.../builtins/process/proc_terminate.md | 56 +
docs/internals/builtins/process/readline.md | 4 +-
docs/internals/builtins/process/shell_exec.md | 4 +-
docs/internals/builtins/process/sleep.md | 4 +-
docs/internals/builtins/process/system.md | 4 +-
docs/internals/builtins/process/usleep.md | 4 +-
.../internals/builtins/regex/mb_ereg_match.md | 4 +-
docs/internals/builtins/regex/preg_match.md | 6 +-
.../builtins/regex/preg_match_all.md | 4 +-
docs/internals/builtins/regex/preg_replace.md | 4 +-
.../builtins/regex/preg_replace_callback.md | 4 +-
docs/internals/builtins/regex/preg_split.md | 4 +-
docs/internals/builtins/spl/iterator_apply.md | 4 +-
docs/internals/builtins/spl/iterator_count.md | 4 +-
.../builtins/spl/iterator_to_array.md | 4 +-
docs/internals/builtins/spl/spl_autoload.md | 4 +-
.../builtins/spl/spl_autoload_call.md | 4 +-
.../builtins/spl/spl_autoload_extensions.md | 4 +-
.../builtins/spl/spl_autoload_functions.md | 4 +-
.../builtins/spl/spl_autoload_register.md | 4 +-
.../builtins/spl/spl_autoload_unregister.md | 4 +-
docs/internals/builtins/spl/spl_classes.md | 4 +-
.../internals/builtins/spl/spl_object_hash.md | 4 +-
docs/internals/builtins/spl/spl_object_id.md | 4 +-
docs/internals/builtins/streams/fsockopen.md | 8 +-
docs/internals/builtins/streams/pfsockopen.md | 6 +-
.../builtins/streams/stream_bucket_append.md | 4 +-
.../builtins/streams/stream_bucket_prepend.md | 4 +-
.../builtins/streams/stream_filter_append.md | 4 +-
.../builtins/streams/stream_filter_prepend.md | 4 +-
docs/internals/builtins/string/addslashes.md | 4 +-
.../builtins/string/base64_decode.md | 4 +-
.../builtins/string/base64_encode.md | 4 +-
docs/internals/builtins/string/bin2hex.md | 4 +-
docs/internals/builtins/string/chop.md | 4 +-
docs/internals/builtins/string/chr.md | 4 +-
docs/internals/builtins/string/crc32.md | 4 +-
.../builtins/string/escapeshellarg.md | 56 +
.../builtins/string/escapeshellcmd.md | 56 +
docs/internals/builtins/string/explode.md | 4 +-
.../builtins/string/grapheme_strrev.md | 4 +-
docs/internals/builtins/string/gzcompress.md | 4 +-
docs/internals/builtins/string/gzdeflate.md | 4 +-
docs/internals/builtins/string/gzinflate.md | 4 +-
.../internals/builtins/string/gzuncompress.md | 4 +-
docs/internals/builtins/string/hash.md | 4 +-
docs/internals/builtins/string/hash_algos.md | 4 +-
docs/internals/builtins/string/hash_copy.md | 4 +-
docs/internals/builtins/string/hash_equals.md | 4 +-
docs/internals/builtins/string/hash_final.md | 4 +-
docs/internals/builtins/string/hash_hmac.md | 4 +-
docs/internals/builtins/string/hash_init.md | 4 +-
docs/internals/builtins/string/hash_update.md | 4 +-
docs/internals/builtins/string/hex2bin.md | 4 +-
.../builtins/string/html_entity_decode.md | 4 +-
.../internals/builtins/string/htmlentities.md | 4 +-
.../builtins/string/htmlspecialchars.md | 4 +-
docs/internals/builtins/string/implode.md | 4 +-
docs/internals/builtins/string/inet_ntop.md | 4 +-
docs/internals/builtins/string/inet_pton.md | 4 +-
docs/internals/builtins/string/ip2long.md | 4 +-
docs/internals/builtins/string/lcfirst.md | 4 +-
docs/internals/builtins/string/long2ip.md | 4 +-
docs/internals/builtins/string/ltrim.md | 4 +-
docs/internals/builtins/string/mb_strlen.md | 8 +-
docs/internals/builtins/string/md5.md | 4 +-
docs/internals/builtins/string/nl2br.md | 4 +-
.../builtins/string/number_format.md | 4 +-
docs/internals/builtins/string/ord.md | 4 +-
docs/internals/builtins/string/printf.md | 4 +-
.../internals/builtins/string/rawurldecode.md | 4 +-
.../internals/builtins/string/rawurlencode.md | 4 +-
docs/internals/builtins/string/rtrim.md | 4 +-
docs/internals/builtins/string/sha1.md | 4 +-
docs/internals/builtins/string/sprintf.md | 4 +-
docs/internals/builtins/string/sscanf.md | 4 +-
.../internals/builtins/string/str_contains.md | 4 +-
.../builtins/string/str_ends_with.md | 4 +-
.../internals/builtins/string/str_ireplace.md | 4 +-
docs/internals/builtins/string/str_pad.md | 4 +-
docs/internals/builtins/string/str_repeat.md | 4 +-
docs/internals/builtins/string/str_replace.md | 4 +-
docs/internals/builtins/string/str_split.md | 4 +-
.../builtins/string/str_starts_with.md | 4 +-
docs/internals/builtins/string/strcasecmp.md | 4 +-
docs/internals/builtins/string/strcmp.md | 4 +-
.../internals/builtins/string/stripslashes.md | 4 +-
docs/internals/builtins/string/strlen.md | 4 +-
docs/internals/builtins/string/strpos.md | 4 +-
docs/internals/builtins/string/strrev.md | 4 +-
docs/internals/builtins/string/strrpos.md | 4 +-
docs/internals/builtins/string/strstr.md | 4 +-
docs/internals/builtins/string/strtolower.md | 4 +-
docs/internals/builtins/string/strtoupper.md | 4 +-
docs/internals/builtins/string/substr.md | 4 +-
.../builtins/string/substr_replace.md | 4 +-
docs/internals/builtins/string/trim.md | 4 +-
docs/internals/builtins/string/ucfirst.md | 4 +-
docs/internals/builtins/string/ucwords.md | 4 +-
docs/internals/builtins/string/urldecode.md | 4 +-
docs/internals/builtins/string/urlencode.md | 4 +-
docs/internals/builtins/string/vprintf.md | 4 +-
docs/internals/builtins/string/vsprintf.md | 4 +-
docs/internals/builtins/string/wordwrap.md | 4 +-
docs/internals/builtins/type/boolval.md | 4 +-
docs/internals/builtins/type/ctype_alnum.md | 4 +-
docs/internals/builtins/type/ctype_alpha.md | 4 +-
docs/internals/builtins/type/ctype_digit.md | 4 +-
docs/internals/builtins/type/ctype_space.md | 4 +-
docs/internals/builtins/type/floatval.md | 4 +-
.../builtins/type/get_resource_id.md | 4 +-
.../builtins/type/get_resource_type.md | 4 +-
docs/internals/builtins/type/gettype.md | 4 +-
docs/internals/builtins/type/intval.md | 4 +-
docs/internals/builtins/type/is_array.md | 4 +-
docs/internals/builtins/type/is_bool.md | 4 +-
docs/internals/builtins/type/is_callable.md | 4 +-
docs/internals/builtins/type/is_double.md | 4 +-
docs/internals/builtins/type/is_float.md | 4 +-
docs/internals/builtins/type/is_int.md | 4 +-
docs/internals/builtins/type/is_integer.md | 4 +-
docs/internals/builtins/type/is_iterable.md | 4 +-
docs/internals/builtins/type/is_long.md | 4 +-
docs/internals/builtins/type/is_null.md | 4 +-
docs/internals/builtins/type/is_numeric.md | 4 +-
docs/internals/builtins/type/is_object.md | 4 +-
docs/internals/builtins/type/is_real.md | 4 +-
docs/internals/builtins/type/is_resource.md | 4 +-
docs/internals/builtins/type/is_scalar.md | 4 +-
docs/internals/builtins/type/is_string.md | 4 +-
docs/internals/builtins/type/settype.md | 6 +-
docs/internals/builtins/type/strval.md | 4 +-
docs/internals/memory-model.md | 10 +-
docs/internals/the-runtime.md | 36 +-
docs/php/builtins.md | 81 +-
docs/php/builtins/array.md | 40 +-
docs/php/builtins/array/array_multisort.md | 2 +-
docs/php/builtins/array/array_pop.md | 2 +-
docs/php/builtins/array/array_push.md | 2 +-
docs/php/builtins/array/array_shift.md | 2 +-
docs/php/builtins/array/array_splice.md | 2 +-
docs/php/builtins/array/array_unshift.md | 2 +-
docs/php/builtins/array/array_walk.md | 2 +-
.../builtins/array/array_walk_recursive.md | 2 +-
docs/php/builtins/array/arsort.md | 2 +-
docs/php/builtins/array/asort.md | 2 +-
docs/php/builtins/array/krsort.md | 2 +-
docs/php/builtins/array/ksort.md | 2 +-
docs/php/builtins/array/natcasesort.md | 2 +-
docs/php/builtins/array/natsort.md | 2 +-
docs/php/builtins/array/rsort.md | 2 +-
docs/php/builtins/array/shuffle.md | 2 +-
docs/php/builtins/array/sort.md | 2 +-
docs/php/builtins/array/uasort.md | 2 +-
docs/php/builtins/array/uksort.md | 2 +-
docs/php/builtins/array/usort.md | 2 +-
docs/php/builtins/filesystem.md | 14 +-
docs/php/builtins/filesystem/chgrp.md | 4 +-
docs/php/builtins/filesystem/chown.md | 4 +-
docs/php/builtins/filesystem/filemtime.md | 4 +-
docs/php/builtins/filesystem/filesize.md | 4 +-
docs/php/builtins/filesystem/lchgrp.md | 4 +-
docs/php/builtins/filesystem/lchown.md | 4 +-
docs/php/builtins/filesystem/tempnam.md | 4 +-
docs/php/builtins/io.md | 10 +-
docs/php/builtins/io/flock.md | 2 +-
docs/php/builtins/io/stream_select.md | 2 +-
docs/php/builtins/io/stream_socket_accept.md | 2 +-
.../io/stream_socket_enable_crypto.md | 4 +-
.../php/builtins/io/stream_socket_recvfrom.md | 2 +-
docs/php/builtins/math.md | 1 +
docs/php/builtins/math/random_bytes.md | 36 +
docs/php/builtins/math/random_int.md | 2 +-
docs/php/builtins/math/round.md | 2 +-
docs/php/builtins/math/sin.md | 2 +-
docs/php/builtins/math/sinh.md | 2 +-
docs/php/builtins/math/sqrt.md | 2 +-
docs/php/builtins/math/tan.md | 2 +-
docs/php/builtins/math/tanh.md | 2 +-
docs/php/builtins/misc/buffer_new.md | 2 +-
docs/php/builtins/misc/define.md | 2 +-
docs/php/builtins/misc/defined.md | 2 +-
docs/php/builtins/misc/empty.md | 2 +-
docs/php/builtins/misc/header.md | 2 +-
docs/php/builtins/misc/http_response_code.md | 2 +-
docs/php/builtins/misc/isset.md | 2 +-
docs/php/builtins/misc/php_uname.md | 2 +-
docs/php/builtins/misc/phpversion.md | 2 +-
docs/php/builtins/misc/print_r.md | 2 +-
docs/php/builtins/misc/serialize.md | 2 +-
docs/php/builtins/misc/unserialize.md | 2 +-
docs/php/builtins/misc/unset.md | 2 +-
docs/php/builtins/misc/var_dump.md | 2 +-
docs/php/builtins/pointer/ptr.md | 2 +-
docs/php/builtins/pointer/ptr_get.md | 2 +-
docs/php/builtins/pointer/ptr_is_null.md | 2 +-
docs/php/builtins/pointer/ptr_null.md | 2 +-
docs/php/builtins/pointer/ptr_offset.md | 2 +-
docs/php/builtins/pointer/ptr_read16.md | 2 +-
docs/php/builtins/pointer/ptr_read32.md | 2 +-
docs/php/builtins/pointer/ptr_read8.md | 2 +-
docs/php/builtins/pointer/ptr_read_string.md | 2 +-
docs/php/builtins/pointer/ptr_set.md | 2 +-
docs/php/builtins/pointer/ptr_sizeof.md | 2 +-
docs/php/builtins/pointer/ptr_write16.md | 2 +-
docs/php/builtins/pointer/ptr_write32.md | 2 +-
docs/php/builtins/pointer/ptr_write8.md | 2 +-
docs/php/builtins/pointer/ptr_write_string.md | 2 +-
docs/php/builtins/pointer/zval_free.md | 2 +-
docs/php/builtins/pointer/zval_pack.md | 2 +-
docs/php/builtins/pointer/zval_type.md | 2 +-
docs/php/builtins/pointer/zval_unpack.md | 2 +-
docs/php/builtins/process.md | 4 +
docs/php/builtins/process/die.md | 2 +-
docs/php/builtins/process/exec.md | 2 +-
docs/php/builtins/process/exit.md | 2 +-
docs/php/builtins/process/passthru.md | 2 +-
docs/php/builtins/process/pclose.md | 2 +-
docs/php/builtins/process/popen.md | 2 +-
docs/php/builtins/process/proc_close.md | 36 +
docs/php/builtins/process/proc_get_status.md | 39 +
docs/php/builtins/process/proc_open.md | 46 +
docs/php/builtins/process/proc_terminate.md | 39 +
docs/php/builtins/process/readline.md | 2 +-
docs/php/builtins/process/shell_exec.md | 2 +-
docs/php/builtins/process/sleep.md | 2 +-
docs/php/builtins/process/system.md | 2 +-
docs/php/builtins/process/usleep.md | 2 +-
docs/php/builtins/regex.md | 2 +-
docs/php/builtins/regex/mb_ereg_match.md | 2 +-
docs/php/builtins/regex/preg_match.md | 4 +-
docs/php/builtins/regex/preg_match_all.md | 2 +-
docs/php/builtins/regex/preg_replace.md | 2 +-
.../builtins/regex/preg_replace_callback.md | 2 +-
docs/php/builtins/regex/preg_split.md | 2 +-
docs/php/builtins/spl/iterator_apply.md | 2 +-
docs/php/builtins/spl/iterator_count.md | 2 +-
docs/php/builtins/spl/iterator_to_array.md | 2 +-
docs/php/builtins/spl/spl_autoload.md | 2 +-
docs/php/builtins/spl/spl_autoload_call.md | 2 +-
.../builtins/spl/spl_autoload_extensions.md | 2 +-
.../builtins/spl/spl_autoload_functions.md | 2 +-
.../php/builtins/spl/spl_autoload_register.md | 2 +-
.../builtins/spl/spl_autoload_unregister.md | 2 +-
docs/php/builtins/spl/spl_classes.md | 2 +-
docs/php/builtins/spl/spl_object_hash.md | 2 +-
docs/php/builtins/spl/spl_object_id.md | 2 +-
docs/php/builtins/streams.md | 4 +-
docs/php/builtins/streams/fsockopen.md | 4 +-
docs/php/builtins/streams/pfsockopen.md | 4 +-
.../builtins/streams/stream_bucket_append.md | 2 +-
.../builtins/streams/stream_bucket_prepend.md | 2 +-
.../builtins/streams/stream_filter_append.md | 2 +-
.../builtins/streams/stream_filter_prepend.md | 2 +-
docs/php/builtins/string.md | 4 +-
docs/php/builtins/string/addslashes.md | 2 +-
docs/php/builtins/string/base64_decode.md | 2 +-
docs/php/builtins/string/base64_encode.md | 2 +-
docs/php/builtins/string/bin2hex.md | 2 +-
docs/php/builtins/string/chop.md | 2 +-
docs/php/builtins/string/chr.md | 2 +-
docs/php/builtins/string/crc32.md | 2 +-
docs/php/builtins/string/escapeshellarg.md | 40 +
docs/php/builtins/string/escapeshellcmd.md | 40 +
docs/php/builtins/string/explode.md | 2 +-
docs/php/builtins/string/grapheme_strrev.md | 2 +-
docs/php/builtins/string/gzcompress.md | 2 +-
docs/php/builtins/string/gzdeflate.md | 2 +-
docs/php/builtins/string/gzinflate.md | 2 +-
docs/php/builtins/string/gzuncompress.md | 2 +-
docs/php/builtins/string/hash.md | 2 +-
docs/php/builtins/string/hash_algos.md | 2 +-
docs/php/builtins/string/hash_copy.md | 2 +-
docs/php/builtins/string/hash_equals.md | 2 +-
docs/php/builtins/string/hash_final.md | 2 +-
docs/php/builtins/string/hash_hmac.md | 2 +-
docs/php/builtins/string/hash_init.md | 2 +-
docs/php/builtins/string/hash_update.md | 2 +-
docs/php/builtins/string/hex2bin.md | 2 +-
.../php/builtins/string/html_entity_decode.md | 2 +-
docs/php/builtins/string/htmlentities.md | 2 +-
docs/php/builtins/string/htmlspecialchars.md | 2 +-
docs/php/builtins/string/implode.md | 2 +-
docs/php/builtins/string/inet_ntop.md | 2 +-
docs/php/builtins/string/inet_pton.md | 2 +-
docs/php/builtins/string/ip2long.md | 2 +-
docs/php/builtins/string/lcfirst.md | 2 +-
docs/php/builtins/string/long2ip.md | 2 +-
docs/php/builtins/string/ltrim.md | 2 +-
docs/php/builtins/string/mb_strlen.md | 6 +-
docs/php/builtins/string/md5.md | 2 +-
docs/php/builtins/string/nl2br.md | 2 +-
docs/php/builtins/string/number_format.md | 2 +-
docs/php/builtins/string/ord.md | 2 +-
docs/php/builtins/string/printf.md | 2 +-
docs/php/builtins/string/rawurldecode.md | 2 +-
docs/php/builtins/string/rawurlencode.md | 2 +-
docs/php/builtins/string/rtrim.md | 2 +-
docs/php/builtins/string/sha1.md | 2 +-
docs/php/builtins/string/sprintf.md | 2 +-
docs/php/builtins/string/sscanf.md | 2 +-
docs/php/builtins/string/str_contains.md | 2 +-
docs/php/builtins/string/str_ends_with.md | 2 +-
docs/php/builtins/string/str_ireplace.md | 2 +-
docs/php/builtins/string/str_pad.md | 2 +-
docs/php/builtins/string/str_repeat.md | 2 +-
docs/php/builtins/string/str_replace.md | 2 +-
docs/php/builtins/string/str_split.md | 2 +-
docs/php/builtins/string/str_starts_with.md | 2 +-
docs/php/builtins/string/strcasecmp.md | 2 +-
docs/php/builtins/string/strcmp.md | 2 +-
docs/php/builtins/string/stripslashes.md | 2 +-
docs/php/builtins/string/strlen.md | 2 +-
docs/php/builtins/string/strpos.md | 2 +-
docs/php/builtins/string/strrev.md | 2 +-
docs/php/builtins/string/strrpos.md | 2 +-
docs/php/builtins/string/strstr.md | 2 +-
docs/php/builtins/string/strtolower.md | 2 +-
docs/php/builtins/string/strtoupper.md | 2 +-
docs/php/builtins/string/substr.md | 2 +-
docs/php/builtins/string/substr_replace.md | 2 +-
docs/php/builtins/string/trim.md | 2 +-
docs/php/builtins/string/ucfirst.md | 2 +-
docs/php/builtins/string/ucwords.md | 2 +-
docs/php/builtins/string/urldecode.md | 2 +-
docs/php/builtins/string/urlencode.md | 2 +-
docs/php/builtins/string/vprintf.md | 2 +-
docs/php/builtins/string/vsprintf.md | 2 +-
docs/php/builtins/string/wordwrap.md | 2 +-
docs/php/builtins/type.md | 2 +-
docs/php/builtins/type/boolval.md | 2 +-
docs/php/builtins/type/ctype_alnum.md | 2 +-
docs/php/builtins/type/ctype_alpha.md | 2 +-
docs/php/builtins/type/ctype_digit.md | 2 +-
docs/php/builtins/type/ctype_space.md | 2 +-
docs/php/builtins/type/floatval.md | 2 +-
docs/php/builtins/type/get_resource_id.md | 2 +-
docs/php/builtins/type/get_resource_type.md | 2 +-
docs/php/builtins/type/gettype.md | 2 +-
docs/php/builtins/type/intval.md | 2 +-
docs/php/builtins/type/is_array.md | 2 +-
docs/php/builtins/type/is_bool.md | 2 +-
docs/php/builtins/type/is_callable.md | 2 +-
docs/php/builtins/type/is_double.md | 2 +-
docs/php/builtins/type/is_float.md | 2 +-
docs/php/builtins/type/is_int.md | 2 +-
docs/php/builtins/type/is_integer.md | 2 +-
docs/php/builtins/type/is_iterable.md | 2 +-
docs/php/builtins/type/is_long.md | 2 +-
docs/php/builtins/type/is_null.md | 2 +-
docs/php/builtins/type/is_numeric.md | 2 +-
docs/php/builtins/type/is_object.md | 2 +-
docs/php/builtins/type/is_real.md | 2 +-
docs/php/builtins/type/is_resource.md | 2 +-
docs/php/builtins/type/is_scalar.md | 2 +-
docs/php/builtins/type/is_string.md | 2 +-
docs/php/builtins/type/settype.md | 4 +-
docs/php/builtins/type/strval.md | 2 +-
docs/php/eval.md | 7 +-
docs/php/streams.md | 17 +-
docs/php/strings.md | 2 +
examples/process-pipes/.gitignore | 4 +
examples/process-pipes/main.php | 37 +
examples/shell-escaping/.gitignore | 4 +
examples/shell-escaping/main.php | 7 +
scripts/check_bridge_exports.py | 142 +
scripts/docs/builtin_registry.json | 3782 +++++++++++++----
scripts/docs/elephc_builtins/extract.py | 8 +
scripts/docs/elephc_builtins/registry.py | 82 +-
scripts/docs/elephc_builtins/render.py | 10 +-
scripts/gen_windows_codegen_allowlist.py | 187 +
scripts/tests/test_builtin_docs.py | 83 +
scripts/tests/test_check_bridge_exports.py | 62 +
.../test_gen_windows_codegen_allowlist.py | 147 +
src/autoload/mod.rs | 25 +-
src/builtins/docs.rs | 4 +
src/builtins/io/__elephc_phar_sign_hash.rs | 2 +-
src/builtins/io/chgrp.rs | 2 +-
src/builtins/io/chown.rs | 2 +-
src/builtins/io/filemtime.rs | 4 +-
src/builtins/io/filesize.rs | 4 +-
src/builtins/io/fsockopen.rs | 1 +
src/builtins/io/lchgrp.rs | 2 +-
src/builtins/io/lchown.rs | 2 +-
src/builtins/io/mod.rs | 6 +-
src/builtins/io/proc_close.rs | 23 +
src/builtins/io/proc_get_status.rs | 40 +
src/builtins/io/proc_open.rs | 650 +++
src/builtins/io/proc_terminate.rs | 124 +
src/builtins/io/stream_filter_append.rs | 2 +-
src/builtins/io/stream_filter_prepend.rs | 2 +-
src/builtins/io/stream_socket_client.rs | 1 +
.../io/stream_socket_enable_crypto.rs | 9 +-
src/builtins/io/tempnam.rs | 19 +-
src/builtins/math/mod.rs | 1 +
src/builtins/math/random_bytes.rs | 52 +
src/builtins/mod.rs | 7 +
src/builtins/requirements.rs | 56 +-
src/builtins/semantics.rs | 7 +-
src/builtins/string/escapeshellarg.rs | 74 +
src/builtins/string/escapeshellcmd.rs | 73 +
src/builtins/string/mb_strlen.rs | 5 +-
src/builtins/string/mod.rs | 2 +
src/builtins/system/__elephc_strtotime_raw.rs | 1 +
src/builtins/system/date.rs | 1 +
src/builtins/system/gmdate.rs | 1 +
src/builtins/system/gmmktime.rs | 1 +
src/builtins/system/mktime.rs | 1 +
src/builtins/system/strtotime.rs | 1 +
src/codegen/block_emit.rs | 12 +-
src/codegen/context.rs | 2 +-
src/codegen/eval_callable_helpers.rs | 69 +-
src/codegen/eval_class_constant_helpers.rs | 32 +-
src/codegen/eval_constructor_helpers.rs | 30 +-
src/codegen/eval_method_helpers.rs | 50 +-
src/codegen/eval_property_helpers.rs | 10 +-
src/codegen/eval_reflection_helpers.rs | 4 +-
src/codegen/eval_reflection_owner_helpers.rs | 31 +-
src/codegen/eval_static_property_helpers.rs | 13 +-
src/codegen/frame.rs | 129 +-
src/codegen/frame/tests.rs | 97 +
src/codegen/literal_defaults.rs | 4 +-
src/codegen/lower_inst.rs | 249 +-
src/codegen/lower_inst/builtins.rs | 15 +-
src/codegen/lower_inst/builtins/arrays.rs | 179 +-
src/codegen/lower_inst/builtins/debug.rs | 7 +-
src/codegen/lower_inst/builtins/eval.rs | 2087 ++++-----
src/codegen/lower_inst/builtins/io.rs | 1503 ++++++-
src/codegen/lower_inst/builtins/math.rs | 55 +-
.../lower_inst/builtins/math/binary.rs | 6 +-
src/codegen/lower_inst/builtins/math/libm.rs | 15 +-
.../lower_inst/builtins/math/random.rs | 63 +-
src/codegen/lower_inst/builtins/spl.rs | 28 +-
src/codegen/lower_inst/builtins/strings.rs | 168 +-
src/codegen/lower_inst/builtins/system.rs | 45 +-
src/codegen/lower_inst/callables.rs | 18 +-
src/codegen/lower_inst/enums.rs | 30 +-
src/codegen/lower_inst/externs.rs | 6 +-
src/codegen/lower_inst/floats.rs | 4 +-
src/codegen/lower_inst/iterators.rs | 32 +-
src/codegen/lower_inst/objects.rs | 73 +-
src/codegen/lower_inst/objects/reflection.rs | 20 +-
src/codegen/lower_inst/ownership.rs | 45 +-
src/codegen/lower_inst/runtime_calls.rs | 2 +
.../lower_inst/runtime_functions/group_05.rs | 12 +
.../lower_inst/runtime_functions/group_07.rs | 3 +
src/codegen/lower_inst/strings.rs | 35 +-
src/codegen/lower_term.rs | 65 +-
src/codegen/runtime_callable_invoker.rs | 98 +-
src/codegen_support/abi/bootstrap.rs | 65 +-
src/codegen_support/abi/callbacks.rs | 70 +
src/codegen_support/abi/calls/mod.rs | 4 +-
src/codegen_support/abi/calls/outgoing.rs | 79 +-
src/codegen_support/abi/frame.rs | 21 +-
src/codegen_support/abi/mod.rs | 14 +-
src/codegen_support/abi/registers.rs | 88 +-
src/codegen_support/abi/symbols.rs | 16 +-
src/codegen_support/abi/tests.rs | 1 +
src/codegen_support/abi/tests/arguments.rs | 126 +
src/codegen_support/abi/tests/basics.rs | 26 +
src/codegen_support/abi/tests/callbacks.rs | 75 +
src/codegen_support/abi/tests/symbols.rs | 34 +
src/codegen_support/callable_invoker_args.rs | 10 +-
src/codegen_support/cdylib.rs | 227 +-
src/codegen_support/emit.rs | 564 ++-
src/codegen_support/hash_crypto.rs | 9 +-
src/codegen_support/mod.rs | 1 +
src/codegen_support/platform/mod.rs | 10 +
src/codegen_support/platform/target.rs | 209 +-
.../platform/windows_transform.rs | 1229 ++++++
src/codegen_support/prescan.rs | 109 +-
.../runtime/arrays/array_filter.rs | 38 +-
.../runtime/arrays/array_filter_refcounted.rs | 2 +-
.../runtime/arrays/array_find_any_all.rs | 2 +-
.../runtime/arrays/array_get_mixed_key.rs | 8 +-
.../runtime/arrays/array_map.rs | 38 +-
.../runtime/arrays/array_map_mixed.rs | 2 +-
.../runtime/arrays/array_map_str.rs | 4 +-
.../runtime/arrays/array_rand.rs | 22 +
.../runtime/arrays/array_reduce.rs | 2 +-
.../runtime/arrays/array_slice.rs | 21 +-
.../runtime/arrays/array_slice_refcounted.rs | 21 +-
.../runtime/arrays/array_splice.rs | 19 +-
.../runtime/arrays/array_splice_refcounted.rs | 19 +-
.../runtime/arrays/array_to_mixed.rs | 2 +-
.../runtime/arrays/array_udiff_uintersect.rs | 2 +-
.../runtime/arrays/array_walk.rs | 2 +-
.../runtime/arrays/array_walk_recursive.rs | 4 +-
.../runtime/arrays/hash_to_mixed.rs | 2 +-
.../runtime/arrays/heap_alloc.rs | 8 +
.../runtime/arrays/mixed_cast_float.rs | 2 +-
.../runtime/arrays/mixed_free_deep.rs | 37 +-
src/codegen_support/runtime/arrays/mod.rs | 5 +
.../runtime/arrays/random_bytes.rs | 285 ++
.../runtime/arrays/random_u32.rs | 31 +-
.../runtime/arrays/random_uniform.rs | 153 +
src/codegen_support/runtime/arrays/shuffle.rs | 1 +
src/codegen_support/runtime/arrays/usort.rs | 6 +-
.../runtime/arrays/value_error.rs | 22 +-
src/codegen_support/runtime/data/fixed.rs | 230 +-
src/codegen_support/runtime/data/mod.rs | 27 +
src/codegen_support/runtime/emitters.rs | 102 +
src/codegen_support/runtime/eval_bridge.rs | 55 +-
src/codegen_support/runtime/eval_scope.rs | 580 +--
src/codegen_support/runtime/exceptions.rs | 4 +
.../runtime/exceptions/cleanup_frames.rs | 2 +-
.../runtime/exceptions/setjmp.rs | 125 +
.../runtime/exceptions/static_throw.rs | 72 +
src/codegen_support/runtime/fibers/alloc.rs | 4 +-
.../runtime/fibers/api/arm64.rs | 4 +-
.../runtime/fibers/api/x86_64.rs | 4 +-
src/codegen_support/runtime/fibers/entry.rs | 6 +-
src/codegen_support/runtime/fibers/switch.rs | 281 +-
src/codegen_support/runtime/io/basename.rs | 17 +-
src/codegen_support/runtime/io/closedir.rs | 6 +-
src/codegen_support/runtime/io/dirname.rs | 129 +-
src/codegen_support/runtime/io/feof.rs | 10 +-
src/codegen_support/runtime/io/fgets.rs | 20 +-
.../runtime/io/file_get_contents_url.rs | 27 +-
src/codegen_support/runtime/io/fopen.rs | 258 +-
.../runtime/io/format_sockaddr.rs | 4 +-
src/codegen_support/runtime/io/fread.rs | 523 ++-
src/codegen_support/runtime/io/fsockopen.rs | 55 +-
src/codegen_support/runtime/io/ftp.rs | 79 +-
src/codegen_support/runtime/io/fwrite.rs | 125 +-
.../runtime/io/gethostbyaddr.rs | 2 +-
.../runtime/io/getprotobyname.rs | 32 +-
.../runtime/io/getprotobynumber.rs | 39 +-
.../runtime/io/getservbyname.rs | 49 +-
.../runtime/io/getservbyport.rs | 51 +-
.../runtime/io/http_response.rs | 67 +-
src/codegen_support/runtime/io/https.rs | 673 +--
src/codegen_support/runtime/io/inet6_pton.rs | 2 +-
src/codegen_support/runtime/io/mod.rs | 19 +
.../runtime/io/modify_x86_64.rs | 14 +-
.../runtime/io/notification.rs | 8 +-
src/codegen_support/runtime/io/ob_handler.rs | 46 +-
src/codegen_support/runtime/io/opendir.rs | 10 +-
.../runtime/io/opendir_glob.rs | 4 +-
src/codegen_support/runtime/io/phar_read.rs | 70 +-
src/codegen_support/runtime/io/phar_write.rs | 60 +-
src/codegen_support/runtime/io/php_input.rs | 51 +-
.../runtime/io/php_temp_dir.rs | 99 +
.../runtime/io/principal_lookup.rs | 23 +-
src/codegen_support/runtime/io/proc_close.rs | 229 +
src/codegen_support/runtime/io/proc_open.rs | 2612 ++++++++++++
.../runtime/io/proc_open_marshal.rs | 1254 ++++++
.../runtime/io/proc_pipe_registry.rs | 366 ++
src/codegen_support/runtime/io/proc_status.rs | 851 ++++
src/codegen_support/runtime/io/readdir.rs | 6 +-
.../runtime/io/resolve_host.rs | 2 +-
.../runtime/io/resolve_host_v6.rs | 6 +-
src/codegen_support/runtime/io/rewinddir.rs | 5 +-
src/codegen_support/runtime/io/scandir.rs | 10 +-
.../runtime/io/stash_connect_host.rs | 176 +-
src/codegen_support/runtime/io/stat.rs | 53 +-
src/codegen_support/runtime/io/stat_array.rs | 13 +-
src/codegen_support/runtime/io/stat_ext.rs | 65 +-
.../runtime/io/stdout_write.rs | 16 +-
.../io/stream_context_get_bool_option.rs | 182 +
.../runtime/io/stream_get_contents.rs | 157 +-
.../runtime/io/stream_get_line.rs | 21 +-
.../runtime/io/stream_get_meta_data.rs | 27 +-
.../runtime/io/stream_isatty.rs | 67 +-
.../runtime/io/stream_select.rs | 48 +-
.../runtime/io/stream_set_blocking.rs | 39 +-
.../runtime/io/stream_set_timeout.rs | 47 +-
.../runtime/io/stream_socket_accept.rs | 2 +-
.../runtime/io/stream_socket_get_name.rs | 2 +-
.../runtime/io/stream_socket_pair.rs | 14 +-
.../runtime/io/stream_socket_recvfrom.rs | 2 +-
.../runtime/io/stream_socket_server.rs | 42 +-
.../runtime/io/stream_socket_server_v6.rs | 2 +-
src/codegen_support/runtime/io/streams_ext.rs | 273 +-
src/codegen_support/runtime/io/tempnam.rs | 183 +-
src/codegen_support/runtime/io/tls_scheme.rs | 215 +
.../runtime/io/tls_session_table.rs | 222 +
src/codegen_support/runtime/io/user_filter.rs | 140 +-
.../runtime/io/user_filter_brigade.rs | 49 +-
.../runtime/io/user_wrapper.rs | 56 +-
.../runtime/io/user_wrapper_cast.rs | 2 +-
.../runtime/io/user_wrapper_dir.rs | 8 +-
.../runtime/io/user_wrapper_path_op.rs | 103 +-
.../runtime/io/user_wrapper_set_option.rs | 2 +-
.../runtime/io/user_wrapper_url_stat.rs | 2 +-
.../runtime/io/var_dump_walk.rs | 13 +-
src/codegen_support/runtime/mod.rs | 3 +
.../runtime/objects/call_destructor.rs | 60 +-
.../runtime/objects/new_by_name.rs | 2 +-
.../runtime/objects/stdclass.rs | 32 +-
.../runtime/spl/doubly_linked_list.rs | 22 +-
.../runtime/spl/fixed_array.rs | 32 +-
src/codegen_support/runtime/strings/ftoa.rs | 631 ++-
src/codegen_support/runtime/strings/hash.rs | 60 +-
.../runtime/strings/hash_context.rs | 58 +-
.../runtime/strings/hash_hmac.rs | 92 +-
.../runtime/strings/mb_strlen.rs | 41 +-
src/codegen_support/runtime/strings/mod.rs | 8 +
.../runtime/strings/number_format.rs | 266 +-
.../runtime/strings/php_round.rs | 457 ++
.../runtime/strings/shell_escape.rs | 945 ++++
.../runtime/strings/sprintf.rs | 571 ++-
.../runtime/strings/sprintf_x86_64.rs | 621 ++-
.../runtime/strings/str_to_int.rs | 4 +-
.../runtime/strings/str_to_number.rs | 4 +-
.../runtime/strings/substr_replace.rs | 37 +-
.../runtime/system/build_argv.rs | 63 +-
.../runtime/system/date/linux_x86_64.rs | 6 +-
.../runtime/system/date_default_timezone.rs | 8 +-
src/codegen_support/runtime/system/getdate.rs | 4 +-
src/codegen_support/runtime/system/getenv.rs | 2 +-
.../system/json_decode_mixed/arrays.rs | 14 +-
.../system/json_decode_mixed/objects.rs | 23 +-
.../system/json_decode_mixed/x86_64.rs | 2 +-
.../runtime/system/json_encode_object.rs | 2 +-
.../runtime/system/json_ftoa.rs | 8 +-
.../runtime/system/localtime.rs | 4 +-
.../runtime/system/mb_ereg_match.rs | 6 +-
.../runtime/system/microtime.rs | 33 +-
src/codegen_support/runtime/system/mktime.rs | 4 +-
.../runtime/system/php_uname.rs | 2 +-
.../runtime/system/preg_match.rs | 20 +-
.../runtime/system/preg_match_all.rs | 6 +-
.../runtime/system/preg_replace.rs | 6 +-
.../runtime/system/preg_replace_callback.rs | 50 +-
.../runtime/system/preg_split.rs | 12 +-
.../runtime/system/regex_locale.rs | 4 +-
.../runtime/system/serialize.rs | 4 +-
.../runtime/system/shell_exec.rs | 4 +-
.../runtime/system/strtotime/keywords.rs | 4 +-
src/codegen_support/runtime/system/time.rs | 2 +-
.../runtime/system/unserialize.rs | 90 +-
src/codegen_support/runtime/win32/imports.rs | 383 ++
src/codegen_support/runtime/win32/mod.rs | 225 +
.../runtime/win32/shims_c_symbols.rs | 2360 ++++++++++
.../runtime/win32/shims_compress.rs | 401 ++
.../runtime/win32/shims_encoding.rs | 118 +
.../runtime/win32/shims_errors.rs | 89 +
src/codegen_support/runtime/win32/shims_fs.rs | 2323 ++++++++++
.../runtime/win32/shims_misc.rs | 905 ++++
.../runtime/win32/shims_net.rs | 1943 +++++++++
.../runtime/win32/shims_pcre.rs | 139 +
.../runtime/win32/shims_time.rs | 710 ++++
src/codegen_support/runtime/win32/tests.rs | 2814 ++++++++++++
src/codegen_support/runtime_features.rs | 69 +-
src/codegen_support/stream_filters/bzip2.rs | 105 +-
.../stream_filters/compress_bzip2_stream.rs | 8 +-
src/codegen_support/stream_filters/iconv.rs | 22 +-
.../stream_filters/iconv_write.rs | 96 +-
src/codegen_support/stream_filters/inflate.rs | 33 +-
src/codegen_support/stream_filters/zlib.rs | 205 +-
src/codegen_support/tls.rs | 212 +-
src/codegen_support/tz_bridge.rs | 51 +
src/codegen_support/wrappers/callback.rs | 78 +-
.../wrappers/callback/descriptor.rs | 140 +-
src/codegen_support/wrappers/fiber.rs | 41 +-
src/debug_info.rs | 18 +-
src/ir/runtime_call.rs | 4 +
src/ir/runtime_fn.rs | 51 +-
src/ir_lower/context.rs | 4 +
src/ir_lower/expr/mod.rs | 199 +-
src/ir_lower/function.rs | 9 +
src/ir_lower/program.rs | 28 +-
src/ir_lower/tests/mod.rs | 81 +-
src/ir_passes/regalloc.rs | 28 +-
src/ir_passes/tests/regalloc_test.rs | 113 +
src/lexer/literals/identifiers.rs | 1 +
src/lexer/token.rs | 2 +
src/lib.rs | 1 +
src/linker.rs | 524 ++-
src/magic_constants/file_pass.rs | 8 +-
src/magic_constants/scope_pass.rs | 8 +-
src/main.rs | 44 +-
src/name_resolver/mod.rs | 54 +-
src/name_resolver/names.rs | 94 +-
src/name_resolver/symbols.rs | 25 +-
src/optimize/control/dce.rs | 2 +-
src/optimize/control/prune.rs | 2 +-
src/optimize/control/prune/statements.rs | 4 +-
src/optimize/effects.rs | 7 +
src/optimize/effects/calls.rs | 7 +-
src/optimize/tests/dce/tries/try_pruning.rs | 57 +-
src/parser/expr/prefix.rs | 15 +-
src/parser/stmt/namespace_use.rs | 3 +-
src/pipeline.rs | 32 +-
src/runtime_cache.rs | 80 +-
src/source_path.rs | 73 +
.../checker/builtin_spl_classes/filesystem.rs | 29 +-
src/types/checker/builtins/callables.rs | 6 +-
src/types/checker/builtins/catalog.rs | 79 +
src/types/checker/builtins/mod.rs | 26 +-
src/types/checker/builtins/spl.rs | 5 +-
src/types/checker/callables/first_class.rs | 12 +-
src/types/checker/driver/functions.rs | 5 +-
src/types/checker/driver/init.rs | 26 +-
src/types/checker/inference/expr/effects.rs | 27 +
.../checker/inference/objects/constructors.rs | 77 +-
.../objects/constructors/reflection.rs | 8 +-
.../checker/inference/objects/methods.rs | 9 +
src/types/result.rs | 103 +
src/windows_toolchain.rs | 327 ++
tests/cdylib_tests.rs | 86 +
.../arrays/indexed/slice_stack_range.rs | 34 +
.../codegen/callables/constants_and_system.rs | 178 +-
.../casts_and_constants/math_builtins.rs | 131 +
tests/codegen/cli.rs | 5 +-
tests/codegen/eval.rs | 886 ++--
tests/codegen/eval_builtin_parity.rs | 5 +-
tests/codegen/eval_callable_ref_errors.rs | 76 +-
tests/codegen/ffi/extern_calls.rs | 12 +
tests/codegen/ffi/memory.rs | 12 +-
tests/codegen/ffi/syntax_and_callbacks.rs | 4 +
tests/codegen/fibers/arguments.rs | 11 +-
tests/codegen/io.rs | 2 +
tests/codegen/io/filesystem.rs | 432 +-
tests/codegen/io/modify.rs | 210 +-
tests/codegen/io/paths/basename_dirname.rs | 36 +-
tests/codegen/io/paths/realpath_pathinfo.rs | 85 +
tests/codegen/io/proc.rs | 415 ++
tests/codegen/io/stat_ext.rs | 250 +-
tests/codegen/io/streams.rs | 869 +++-
tests/codegen/io/streams_ext.rs | 29 +-
tests/codegen/magic_constants.rs | 40 +-
tests/codegen/mod.rs | 2 +-
tests/codegen/numeric_scalars.rs | 19 +
tests/codegen/oop/datetime.rs | 23 +
tests/codegen/oop/modifiers_and_properties.rs | 4 +-
.../guards/composite_guards.rs | 10 +-
tests/codegen/optimizer/release_local_slot.rs | 4 +-
tests/codegen/runtime_gc.rs | 2 +
tests/codegen/runtime_gc/heap.rs | 13 +
.../runtime_gc/oversized_read_release.rs | 84 +
tests/codegen/runtime_gc/regressions.rs | 30 +-
.../runtime_gc/resource_scope_cleanup.rs | 22 +-
tests/codegen/scalar_strings.rs | 14 +-
tests/codegen/serialize.rs | 6 +-
tests/codegen/strings/search.rs | 262 ++
tests/codegen/strings/transform.rs | 110 +
tests/codegen/support/compiler.rs | 114 +-
tests/codegen/support/platform.rs | 179 +-
tests/codegen/support/projects.rs | 75 +-
tests/codegen/support/runner.rs | 335 +-
tests/codegen/types/enums.rs | 29 +-
tests/codegen/types/never.rs | 7 +-
tests/codegen/windows_pe.rs | 1601 +++++++
tests/error_tests/io_builtins/streams.rs | 118 +
tests/error_tests/math_builtins.rs | 36 +
tests/error_tests/string_builtins.rs | 57 +
tests/ir_backend_smoke_test.rs | 13 +-
tests/lexer_tests/constants.rs | 14 +-
1207 files changed, 56844 insertions(+), 7156 deletions(-)
create mode 100644 crates/elephc-magician/src/interpreter/builtins/filesystem/proc_close.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/filesystem/proc_get_status.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/filesystem/proc_open.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/filesystem/proc_terminate.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/math/random_bytes.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/string/escapeshellarg.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/string/escapeshellcmd.rs
create mode 100644 crates/elephc-magician/src/interpreter/builtins/string/shell_escape.rs
create mode 100644 docs/internals/builtins/math/random_bytes.md
create mode 100644 docs/internals/builtins/process/proc_close.md
create mode 100644 docs/internals/builtins/process/proc_get_status.md
create mode 100644 docs/internals/builtins/process/proc_open.md
create mode 100644 docs/internals/builtins/process/proc_terminate.md
create mode 100644 docs/internals/builtins/string/escapeshellarg.md
create mode 100644 docs/internals/builtins/string/escapeshellcmd.md
create mode 100644 docs/php/builtins/math/random_bytes.md
create mode 100644 docs/php/builtins/process/proc_close.md
create mode 100644 docs/php/builtins/process/proc_get_status.md
create mode 100644 docs/php/builtins/process/proc_open.md
create mode 100644 docs/php/builtins/process/proc_terminate.md
create mode 100644 docs/php/builtins/string/escapeshellarg.md
create mode 100644 docs/php/builtins/string/escapeshellcmd.md
create mode 100644 examples/process-pipes/.gitignore
create mode 100644 examples/process-pipes/main.php
create mode 100644 examples/shell-escaping/.gitignore
create mode 100644 examples/shell-escaping/main.php
create mode 100755 scripts/check_bridge_exports.py
create mode 100755 scripts/gen_windows_codegen_allowlist.py
create mode 100644 scripts/tests/test_builtin_docs.py
create mode 100755 scripts/tests/test_check_bridge_exports.py
create mode 100644 scripts/tests/test_gen_windows_codegen_allowlist.py
create mode 100644 src/builtins/io/proc_close.rs
create mode 100644 src/builtins/io/proc_get_status.rs
create mode 100644 src/builtins/io/proc_open.rs
create mode 100644 src/builtins/io/proc_terminate.rs
create mode 100644 src/builtins/math/random_bytes.rs
create mode 100644 src/builtins/string/escapeshellarg.rs
create mode 100644 src/builtins/string/escapeshellcmd.rs
create mode 100644 src/codegen_support/abi/callbacks.rs
create mode 100644 src/codegen_support/abi/tests/callbacks.rs
create mode 100644 src/codegen_support/platform/windows_transform.rs
create mode 100644 src/codegen_support/runtime/arrays/random_bytes.rs
create mode 100644 src/codegen_support/runtime/exceptions/setjmp.rs
create mode 100644 src/codegen_support/runtime/exceptions/static_throw.rs
create mode 100644 src/codegen_support/runtime/io/php_temp_dir.rs
create mode 100644 src/codegen_support/runtime/io/proc_close.rs
create mode 100644 src/codegen_support/runtime/io/proc_open.rs
create mode 100644 src/codegen_support/runtime/io/proc_open_marshal.rs
create mode 100644 src/codegen_support/runtime/io/proc_pipe_registry.rs
create mode 100644 src/codegen_support/runtime/io/proc_status.rs
create mode 100644 src/codegen_support/runtime/io/stream_context_get_bool_option.rs
create mode 100644 src/codegen_support/runtime/io/tls_scheme.rs
create mode 100644 src/codegen_support/runtime/io/tls_session_table.rs
create mode 100644 src/codegen_support/runtime/strings/php_round.rs
create mode 100644 src/codegen_support/runtime/strings/shell_escape.rs
create mode 100644 src/codegen_support/runtime/win32/imports.rs
create mode 100644 src/codegen_support/runtime/win32/mod.rs
create mode 100644 src/codegen_support/runtime/win32/shims_c_symbols.rs
create mode 100644 src/codegen_support/runtime/win32/shims_compress.rs
create mode 100644 src/codegen_support/runtime/win32/shims_encoding.rs
create mode 100644 src/codegen_support/runtime/win32/shims_errors.rs
create mode 100644 src/codegen_support/runtime/win32/shims_fs.rs
create mode 100644 src/codegen_support/runtime/win32/shims_misc.rs
create mode 100644 src/codegen_support/runtime/win32/shims_net.rs
create mode 100644 src/codegen_support/runtime/win32/shims_pcre.rs
create mode 100644 src/codegen_support/runtime/win32/shims_time.rs
create mode 100644 src/codegen_support/runtime/win32/tests.rs
create mode 100644 src/codegen_support/tz_bridge.rs
create mode 100644 src/source_path.rs
create mode 100644 src/windows_toolchain.rs
create mode 100644 tests/codegen/io/proc.rs
create mode 100644 tests/codegen/runtime_gc/oversized_read_release.rs
create mode 100644 tests/codegen/windows_pe.rs
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 0e8e4e1da3..20ab50745c 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -38,6 +38,14 @@ relative-to = "target"
path = "debug/libelephc_web.a"
relative-to = "target"
+# Emit a machine-readable JUnit report for every `--profile ci` run. This is
+# purely additive (it writes `target/nextest/ci/junit.xml` and changes no test
+# outcome or console output), so the native codegen/non-codegen jobs are
+# unaffected. The `windows-codegen-gate` aggregate parses all 16 native Windows
+# reports and requires exactly one successful execution of every runnable test.
+[profile.ci.junit]
+path = "junit.xml"
+
# The `ir_backend_parity` first-class-callable case bundles 15 legacy-vs-EIR
# parity programs (several link the PCRE staticlib), each compiled and run twice.
# It legitimately runs ~65-70s, just over the global 60s cap, so it needs a
@@ -79,6 +87,26 @@ slow-timeout = { period = "180s", terminate-after = 1 }
filter = 'test(test_eval_rejects_invalid_property_and_parameter_type_atoms)'
slow-timeout = { period = "180s", terminate-after = 1 }
+# Every `codegen::eval` fixture drives the eval bridge, which compiles AND LINKS a
+# complete PHP program while the test is running -- the work the other entries in
+# this file get an override for, except that here it is the whole module rather
+# than a handful of cases. On an unloaded macOS AArch64 host they measure ~18-38s
+# each; on a loaded native-Windows shard, where the link is MinGW's, that lands on
+# either side of the 60s guard depending on the runner.
+#
+# The override is deliberately module-wide rather than a list of names. Naming the
+# two cases that tripped on one run only moved the problem: the next run terminated
+# two DIFFERENT eval fixtures on the same revision, because which of them crosses
+# the line is a property of runner load, not of the individual test. None of them
+# hangs -- each one that has been allowed to finish did.
+[[profile.default.overrides]]
+filter = 'test(/^codegen::eval::/)'
+slow-timeout = { period = "180s", terminate-after = 1 }
+
+[[profile.ci.overrides]]
+filter = 'test(/^codegen::eval::/)'
+slow-timeout = { period = "180s", terminate-after = 1 }
+
[[profile.default.overrides]]
filter = 'test(test_eval_declared_inherited_property_redeclaration_contracts)'
slow-timeout = { period = "180s", terminate-after = 1 }
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9b17a08248..df9fb6e3a1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,22 @@ env:
-p elephc-image
-p elephc-magician
-p elephc-web
+ # Windows parity prebuild set: the bridges that cross-build cleanly for
+ # x86_64-pc-windows-gnu. elephc-phar is pure-Rust (bzip2 backed by
+ # libbz2-rs-sys, zlib backed by miniz_oxide -- no system libbz2/zlib
+ # dependency), so it needs no C toolchain support beyond MinGW. elephc-tls's
+ # `ring` crypto backend also builds cleanly under the provisioned MinGW CC;
+ # its fd-attachment surface uses a full-width Winsock SOCKET and duplicates
+ # it through `TcpStream::try_clone` on Windows. Building this set remains a
+ # structural bridge check; native behavior is covered separately below.
+ BRIDGE_CRATES_WINDOWS: >-
+ -p elephc-image
+ -p elephc-pdo
+ -p elephc-crypto
+ -p elephc-tz
+ -p elephc-phar
+ -p elephc-tls
+ -p elephc-web
jobs:
# Compile each platform once, then let that platform's tests start as soon as
@@ -397,6 +413,15 @@ jobs:
--no-fail-fast --retries 1 --flaky-result pass \
-j 1
+ # Ordinary codegen fixtures are dominated by waiting on the assembler, the
+ # linker, and the compiled program, not by CPU in the test process itself, so
+ # two nextest workers per shard cut shard wall time without adding test runs --
+ # the same trade the eval matrix below already makes. Each fixture builds in
+ # its own pid/tid-keyed temp directory (tests/codegen/support/projects.rs),
+ # binds ephemeral ports, and shares the runtime object cache through an atomic
+ # rename (src/runtime_cache.rs), so concurrent fixtures cannot collide. Median
+ # per-test time is ~0.8s against the profile's 60s slow-timeout, leaving ample
+ # headroom for the added contention.
codegen-tests-macos-aarch64:
name: Codegen Tests (macos-aarch64 ${{ matrix.shard }}/16)
needs: build-archive-macos-aarch64
@@ -432,7 +457,7 @@ jobs:
-E 'binary(codegen_tests) and not test(~codegen::eval)' \
--partition hash:${{ matrix.shard }}/16 \
--no-fail-fast --retries 1 --flaky-result pass \
- -j 1
+ -j 2
codegen-tests-linux-x86_64:
name: Codegen Tests (linux-x86_64 ${{ matrix.shard }}/16)
@@ -468,7 +493,7 @@ jobs:
-E 'binary(codegen_tests) and not test(~codegen::eval)' \
--partition hash:${{ matrix.shard }}/16 \
--no-fail-fast --retries 1 --flaky-result pass \
- -j 1
+ -j 2
codegen-tests-linux-aarch64:
name: Codegen Tests (linux-aarch64 ${{ matrix.shard }}/16)
@@ -504,7 +529,7 @@ jobs:
-E 'binary(codegen_tests) and not test(~codegen::eval)' \
--partition hash:${{ matrix.shard }}/16 \
--no-fail-fast --retries 1 --flaky-result pass \
- -j 1
+ -j 2
# Eval integration tests compile, link, and run full programs while exercising
# eval/magician paths, so keep them out of the ordinary codegen shards. Two
@@ -667,6 +692,9 @@ jobs:
# target/debug/examples/gen_builtins), so it must be built first.
run: cargo build --example gen_builtins
+ - name: Test the builtin docs pipeline
+ run: python3 scripts/tests/test_builtin_docs.py
+
- name: Regenerate builtins documentation
# Re-run the generator; the committed Markdown pages and JSON registry
# must come back byte-identical. A diff means someone changed a builtin
@@ -705,8 +733,15 @@ jobs:
- codegen-tests-macos-aarch64
- codegen-tests-linux-x86_64
- codegen-tests-linux-aarch64
+ - eval-codegen-tests-macos-aarch64
+ - eval-codegen-tests-linux-x86_64
+ - eval-codegen-tests-linux-aarch64
- image-api-sync
- builtins-docs-sync
+ - windows-pe-cross-compile
+ - windows-pe-llvm-lld
+ - windows-bridge-native-build
+ - windows-codegen-gate
if: always()
steps:
- name: Verify test jobs
@@ -723,8 +758,15 @@ jobs:
test "${{ needs.codegen-tests-macos-aarch64.result }}" = "success"
test "${{ needs.codegen-tests-linux-x86_64.result }}" = "success"
test "${{ needs.codegen-tests-linux-aarch64.result }}" = "success"
+ test "${{ needs.eval-codegen-tests-macos-aarch64.result }}" = "success"
+ test "${{ needs.eval-codegen-tests-linux-x86_64.result }}" = "success"
+ test "${{ needs.eval-codegen-tests-linux-aarch64.result }}" = "success"
test "${{ needs.image-api-sync.result }}" = "success"
test "${{ needs.builtins-docs-sync.result }}" = "success"
+ test "${{ needs.windows-pe-cross-compile.result }}" = "success"
+ test "${{ needs.windows-pe-llvm-lld.result }}" = "success"
+ test "${{ needs.windows-bridge-native-build.result }}" = "success"
+ test "${{ needs.windows-codegen-gate.result }}" = "success"
benchmark:
name: Benchmark Suite
@@ -765,3 +807,580 @@ jobs:
benchmark-results.md
magician-benchmark-results.json
magician-benchmark-results.md
+
+ windows-pe-cross-compile:
+ name: Windows PE Cross-Compile Tests
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ # Silence Wine's diagnostic chatter so it never pollutes captured stdout/stderr.
+ WINEDEBUG: -all
+ # This job installs MinGW-w64 and Wine on purpose, so a `windows_pe` fixture
+ # that cannot find either must fail rather than skip: every skip in that
+ # module reports as a pass, which would turn a degraded toolchain install
+ # into a green job that compiled and executed nothing.
+ ELEPHC_REQUIRE_WINDOWS_TOOLCHAIN: "1"
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Add x86_64-pc-windows-gnu Rust target
+ run: rustup target add x86_64-pc-windows-gnu
+
+ - name: Cache Rust build state
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/git
+ ~/.cargo/registry
+ target
+ key: rust-windows-pe-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ rust-windows-pe-
+
+ - name: Install MinGW-w64 cross-compiler
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ binutils-mingw-w64-x86-64 \
+ gcc-mingw-w64-x86-64 \
+ file
+
+ - name: Install Wine
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends wine64 wine
+ # On ubuntu-24.04 the wine64 package only ships the internal
+ # /usr/lib/wine/wine64 loader; the callable binary on PATH is `wine`
+ # (dispatches to the 64-bit loader since wine32/i386 is not installed
+ # and is not needed for x86_64-only PE binaries).
+ command -v wine64 >/dev/null 2>&1 && wine64 --version || wine --version
+
+ - name: Initialize Wine prefix
+ run: |
+ wineboot --init || true
+ wineserver --wait || true
+
+ - name: Check (no warnings)
+ shell: bash
+ run: |
+ set -o pipefail
+ cargo build 2>&1 | tee "$RUNNER_TEMP/cargo-build.log"
+ ! grep -i warning "$RUNNER_TEMP/cargo-build.log"
+
+ - name: Cross-build Windows bridge archives
+ env:
+ CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
+ AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
+ RANLIB_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ranlib
+ run: cargo build $BRIDGE_CRATES_WINDOWS --target x86_64-pc-windows-gnu
+
+ - name: Verify PE/COFF bridge archive exports
+ run: |
+ python3 scripts/check_bridge_exports.py \
+ --target x86_64-pc-windows-gnu \
+ --nm x86_64-w64-mingw32-nm
+
+ - name: Run Windows PE, web export, and Wine HTTP tests
+ run: cargo test --test codegen_tests -- windows_pe --nocapture
+
+ - name: Cross-compile hello-world
+ run: |
+ echo ' /tmp/hello.php
+ cargo run -- --target windows-x86_64 /tmp/hello.php
+ file /tmp/hello.exe
+ # Verify it is a valid PE32+ executable
+ file /tmp/hello.exe | grep -q "PE32+ executable (console) x86-64"
+ # Verify imports include kernel32
+ x86_64-w64-mingw32-objdump -x /tmp/hello.exe | grep -q "KERNEL32.dll"
+
+ - name: Run hello-world under Wine
+ run: |
+ WINE_BIN=wine64
+ command -v wine64 >/dev/null 2>&1 || WINE_BIN=wine
+ "$WINE_BIN" /tmp/hello.exe > /tmp/hello.out
+ cat /tmp/hello.out
+ grep -q "Hello from Windows!" /tmp/hello.out
+
+ - name: Cross-compile arithmetic test
+ run: |
+ echo ' /tmp/arith.php
+ cargo run -- --target windows-x86_64 /tmp/arith.php
+ file /tmp/arith.exe | grep -q "PE32+ executable"
+
+ - name: Cross-compile function call test
+ run: |
+ echo ' /tmp/func.php
+ cargo run -- --target windows-x86_64 /tmp/func.php
+ file /tmp/func.exe | grep -q "PE32+ executable"
+
+ - name: Cross-compile loop test
+ run: |
+ echo ' /tmp/loop.php
+ cargo run -- --target windows-x86_64 /tmp/loop.php
+ file /tmp/loop.exe | grep -q "PE32+ executable"
+
+ - name: Cross-compile string concatenation test
+ run: |
+ echo ' /tmp/concat.php
+ cargo run -- --target windows-x86_64 /tmp/concat.php
+ file /tmp/concat.exe | grep -q "PE32+ executable"
+
+ # Exercise the opt-in LLVM path independently from the GNU/MinGW job above.
+ # This stays focused: it proves PE assembly, linking, Wine execution, transport
+ # visibility, and timeout metadata without duplicating the native 16-way shard set.
+ windows-pe-llvm-lld:
+ name: Windows PE LLVM/LLD Tests
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ # Silence Wine's diagnostic chatter so it never pollutes captured stdout/stderr.
+ WINEDEBUG: -all
+ # As in windows-pe-cross-compile: MinGW-w64, LLVM/LLD, and Wine are all
+ # installed deliberately here, so a missing one must fail the job instead of
+ # silently downgrading these three regressions to vacuous passes.
+ ELEPHC_REQUIRE_WINDOWS_TOOLCHAIN: "1"
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache Rust build state
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/git
+ ~/.cargo/registry
+ target
+ key: rust-windows-pe-llvm-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ rust-windows-pe-llvm-
+
+ - name: Install LLVM, MinGW-w64, and inspection tools
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ binutils-mingw-w64-x86-64 \
+ clang \
+ gcc-mingw-w64-x86-64 \
+ lld \
+ file
+
+ - name: Configure LLVM/LLD MinGW target
+ shell: bash
+ run: |
+ set -euo pipefail
+ sysroot="$(x86_64-w64-mingw32-gcc -print-sysroot)"
+ if [[ -z "$sysroot" || ! -d "$sysroot" ]]; then
+ for candidate in /usr/x86_64-w64-mingw32 /usr/x86_64-w64-mingw32/sys-root/mingw; do
+ if [[ -d "$candidate" ]]; then
+ sysroot="$candidate"
+ break
+ fi
+ done
+ fi
+ test -n "$sysroot"
+ test -d "$sysroot"
+ clang_bin="$(command -v clang)"
+ lld_bin="$(command -v ld.lld)"
+ gcc_bin="$(command -v x86_64-w64-mingw32-gcc)"
+ "$clang_bin" --version
+ "$lld_bin" --version
+ {
+ echo "ELEPHC_WINDOWS_TOOLCHAIN=llvm"
+ echo "ELEPHC_WINDOWS_CLANG=$clang_bin"
+ echo "ELEPHC_WINDOWS_LLD=$lld_bin"
+ echo "ELEPHC_WINDOWS_SYSROOT=$sysroot"
+ echo "ELEPHC_WINDOWS_GCC=$gcc_bin"
+ } >> "$GITHUB_ENV"
+
+ - name: Install Wine
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends wine64 wine
+ # On ubuntu-24.04 the wine64 package only ships the internal
+ # /usr/lib/wine/wine64 loader; `wine` dispatches to it for x86_64 PE.
+ command -v wine64 >/dev/null 2>&1 && wine64 --version || wine --version
+
+ - name: Initialize Wine prefix
+ run: |
+ wineboot --init || true
+ wineserver --wait || true
+
+ - name: Check (no warnings)
+ shell: bash
+ run: |
+ set -o pipefail
+ cargo build 2>&1 | tee "$RUNNER_TEMP/cargo-build.log"
+ ! grep -i warning "$RUNNER_TEMP/cargo-build.log"
+
+ - name: Compile and execute an LLVM/LLD PE smoke test under Wine
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo ' /tmp/hello-llvm.php
+ cargo run -- --target windows-x86_64 /tmp/hello-llvm.php
+ file /tmp/hello-llvm.exe | grep -q "PE32+ executable (console) x86-64"
+ WINE_BIN=wine64
+ command -v wine64 >/dev/null 2>&1 || WINE_BIN=wine
+ "$WINE_BIN" /tmp/hello-llvm.exe > /tmp/hello-llvm.out
+ grep -q "Hello from LLVM/LLD Windows!" /tmp/hello-llvm.out
+
+ - name: Verify LLVM/LLD PE hardening and unwind metadata
+ run: cargo test --test codegen_tests -- test_windows_pe_hardening_and_generated_unwind_metadata --nocapture
+
+ - name: Run LLVM/LLD Wine transport visibility regression
+ run: cargo test --test codegen_tests -- test_windows_does_not_expose_unix_socket_transports --nocapture
+
+ - name: Run LLVM/LLD Wine timeout metadata regression
+ run: cargo test --test codegen_tests -- test_windows_stream_timeout_sets_metadata_without_eof --nocapture
+
+ # Keep bridge/toolchain coverage separate from the strict native codegen
+ # shards below so archive/export regressions have a focused failure signal.
+ windows-bridge-native-build:
+ name: Windows Native PE & Bridge Tests
+ runs-on: windows-2025
+ timeout-minutes: 90
+ env:
+ # A native Windows host needs no Wine, but it still needs the MSYS2 MinGW
+ # toolchain this job installs. Refuse the skip path so a broken MSYS2 setup
+ # fails here instead of reporting the whole PE suite as passed.
+ ELEPHC_REQUIRE_WINDOWS_TOOLCHAIN: "1"
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Exclude the build and scratch trees from Defender
+ # Same rationale as the native codegen shards: this job assembles, links,
+ # and runs the whole PE suite, so real-time scanning taxes every fixture.
+ shell: pwsh
+ continue-on-error: true
+ run: |
+ foreach ($path in @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP,
+ (Join-Path $env:USERPROFILE '.cargo'),
+ (Join-Path $env:USERPROFILE '.rustup'))) {
+ if ($path) { Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue }
+ }
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: llvm-tools-preview
+
+ - name: Install the native MinGW toolchain and libraries
+ id: msys2_bridge
+ uses: msys2/setup-msys2@v2
+ with:
+ msystem: MINGW64
+ update: true
+ install: >-
+ mingw-w64-x86_64-binutils
+ mingw-w64-x86_64-bzip2
+ mingw-w64-x86_64-gcc
+ mingw-w64-x86_64-libiconv
+ mingw-w64-x86_64-pcre2
+ mingw-w64-x86_64-zlib
+
+ - name: Cache Rust build state
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/git
+ ~/.cargo/registry
+ target
+ key: rust-windows-native-bridges-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ rust-windows-native-bridges-
+
+ - name: Build bridge staticlibs with the native MSVC toolchain
+ shell: bash
+ run: |
+ cargo build -p elephc
+ cargo build $BRIDGE_CRATES_WINDOWS
+
+ - name: Verify and expose GNU MinGW tools
+ shell: pwsh
+ run: |
+ $root = '${{ steps.msys2_bridge.outputs.msys2-location }}'
+ $binDir = Join-Path $root 'mingw64\bin'
+ foreach ($tool in @("gcc", "as", "ar", "ranlib", "nm", "objdump")) {
+ $target = Join-Path $binDir "x86_64-w64-mingw32-$tool.exe"
+ if (-not (Test-Path -LiteralPath $target)) {
+ $source = Join-Path $binDir "$tool.exe"
+ if (-not (Test-Path -LiteralPath $source)) {
+ throw "MSYS2 MinGW package setup did not provide $source"
+ }
+ New-Item -ItemType HardLink -Path $target -Target $source | Out-Null
+ }
+ & $target --version | Select-Object -First 1
+ }
+ Add-Content -LiteralPath $env:GITHUB_PATH -Value $binDir
+ Add-Content -LiteralPath $env:GITHUB_ENV `
+ -Value "ELEPHC_MINGW_SYSROOT=$(Join-Path $root 'mingw64')"
+
+ - name: Cross-build GNU/COFF bridge archives for generated programs
+ shell: bash
+ env:
+ CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
+ AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
+ RANLIB_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ranlib
+ run: |
+ rustup target add x86_64-pc-windows-gnu
+ x86_64-w64-mingw32-gcc --version
+ cargo build $BRIDGE_CRATES_WINDOWS --target x86_64-pc-windows-gnu
+
+ - name: Execute the complete PE suite, including native web HTTP probes
+ shell: bash
+ run: cargo test --test codegen_tests windows_pe -- --nocapture
+
+ - name: Run bridge unit tests with a Unicode temporary path
+ shell: bash
+ run: |
+ PROBE_TEMP="$RUNNER_TEMP/elephc space été"
+ mkdir -p "$PROBE_TEMP"
+ TMP="$PROBE_TEMP" TEMP="$PROBE_TEMP" cargo test $BRIDGE_CRATES_WINDOWS
+
+ - name: Verify native COFF bridge archive exports
+ shell: bash
+ run: |
+ HOST="$(rustc -vV | sed -n 's/^host: //p')"
+ LLVM_NM="$(rustc --print sysroot)/lib/rustlib/$HOST/bin/llvm-nm"
+ test -x "$LLVM_NM.exe" && LLVM_NM="$LLVM_NM.exe"
+ python scripts/check_bridge_exports.py --nm "$LLVM_NM"
+
+ - name: Run bridge export verifier structural tests
+ shell: bash
+ run: python scripts/tests/test_check_bridge_exports.py
+
+ # Run every runnable codegen fixture directly on a native Windows host. The
+ # shards are strict: nextest itself is the gate, so no failure baseline or
+ # allow-list can turn a failing fixture green. JUnit artifacts are retained
+ # solely to prove that the 8 hash partitions cover the runnable inventory
+ # exactly once.
+ #
+ # Eight shards of two workers, not sixteen of one. The account's ceiling is 20
+ # concurrent jobs across the whole run; sixteen single-worker Windows shards
+ # claimed 16 of those 20 slots for the first ~38 minutes and starved the macOS
+ # lane, which then ran alone -- 5 jobs against 15 idle slots -- for the final
+ # ~27 minutes. Halving the shard count at double the workers keeps the same
+ # total test work and roughly the same per-shard duration while freeing eight
+ # slots for the rest of the matrix from the start of the run.
+ windows-codegen-native:
+ name: Windows Native Codegen (${{ matrix.shard }}/8)
+ runs-on: windows-2025
+ # Each shard now carries twice the fixtures. The second worker is expected to
+ # absorb that, but the cap must not assume it does: at the measured 16-shard
+ # rate (~32 min) a shard whose second worker bought nothing would need ~67
+ # min, which the previous 90 min cap only just cleared. Per-test hangs are
+ # already caught by the profile's 60s slow-timeout, so this outer cap only
+ # needs to be comfortably clear of the worst plausible honest run.
+ timeout-minutes: 120
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4, 5, 6, 7, 8]
+ env:
+ ELEPHC_TEST_TARGET: windows-x86_64
+ # This job installs MinGW-w64 on purpose, so a fixture that finds no
+ # toolchain must fail rather than skip. Without this, a shard that lost
+ # `x86_64-w64-mingw32-gcc` from PATH would exit 0 out of every fixture and
+ # still hand `windows-codegen-gate` a complete, failure-free JUnit report --
+ # the gate matches testcase names, not assertions, so it would print OK for
+ # a run that executed nothing. See `ensure_windows_runnable_or_skip`.
+ ELEPHC_REQUIRE_WINDOWS_TOOLCHAIN: "1"
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Exclude the build and scratch trees from Defender
+ # Every codegen fixture writes an .s file, runs the MinGW assembler and
+ # linker over it, and executes a brand-new .exe. Real-time scanning meters
+ # each of those, which is why a native Windows shard costs ~4s per fixture
+ # against ~0.8s on Linux for the same work. The runner is an ephemeral,
+ # single-use VM, so scanning its own build output buys nothing. Never fail
+ # the job over this: the exclusion is an optimization, not a requirement.
+ shell: pwsh
+ continue-on-error: true
+ run: |
+ foreach ($path in @($env:GITHUB_WORKSPACE, $env:RUNNER_TEMP, $env:TEMP,
+ (Join-Path $env:USERPROFILE '.cargo'),
+ (Join-Path $env:USERPROFILE '.rustup'))) {
+ if ($path) { Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue }
+ }
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Add the MinGW Rust target
+ run: rustup target add x86_64-pc-windows-gnu
+
+ # GitHub's Windows curl uses Schannel. Its normal CRL distribution-point
+ # lookup intermittently fails on hosted runners with
+ # CRYPT_E_REVOCATION_OFFLINE before nextest is downloaded. Keep TLS,
+ # hostname, chain, and actual-revocation checks enabled; only make an
+ # unavailable CRL endpoint non-fatal for this installation action.
+ - name: Configure curl for the cargo-nextest download
+ id: nextest_curl
+ shell: pwsh
+ run: |
+ $curlHome = Join-Path $env:RUNNER_TEMP 'elephc-nextest-curl'
+ New-Item -ItemType Directory -Path $curlHome -Force | Out-Null
+ Set-Content -LiteralPath (Join-Path $curlHome '.curlrc') `
+ -Value 'ssl-revoke-best-effort' -NoNewline -Encoding ascii
+ "home=$curlHome" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+
+ - name: Install cargo-nextest
+ uses: taiki-e/install-action@nextest
+ env:
+ CURL_HOME: ${{ steps.nextest_curl.outputs.home }}
+
+ - name: Install the native MinGW toolchain and libraries
+ id: msys2_codegen
+ uses: msys2/setup-msys2@v2
+ with:
+ msystem: MINGW64
+ update: true
+ install: >-
+ mingw-w64-x86_64-binutils
+ mingw-w64-x86_64-bzip2
+ mingw-w64-x86_64-gcc
+ mingw-w64-x86_64-libiconv
+ mingw-w64-x86_64-pcre2
+ mingw-w64-x86_64-zlib
+
+ - name: Verify and expose GNU MinGW tools
+ shell: pwsh
+ run: |
+ $root = '${{ steps.msys2_codegen.outputs.msys2-location }}'
+ $binDir = Join-Path $root 'mingw64\bin'
+ foreach ($tool in @("gcc", "as", "ar", "ranlib", "nm", "objdump")) {
+ $target = Join-Path $binDir "x86_64-w64-mingw32-$tool.exe"
+ if (-not (Test-Path -LiteralPath $target)) {
+ $source = Join-Path $binDir "$tool.exe"
+ if (-not (Test-Path -LiteralPath $source)) {
+ throw "MSYS2 MinGW package setup did not provide $source"
+ }
+ New-Item -ItemType HardLink -Path $target -Target $source | Out-Null
+ }
+ & $target --version | Select-Object -First 1
+ }
+ Add-Content -LiteralPath $env:GITHUB_PATH -Value $binDir
+ Add-Content -LiteralPath $env:GITHUB_ENV `
+ -Value "ELEPHC_MINGW_SYSROOT=$(Join-Path $root 'mingw64')"
+
+ - name: Cache Rust build state
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/git
+ ~/.cargo/registry
+ target
+ key: rust-windows-native-codegen-${{ hashFiles('Cargo.lock') }}
+ restore-keys: |
+ rust-windows-native-codegen-
+
+ - name: Build compiler and GNU/COFF bridge archives
+ shell: bash
+ env:
+ CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
+ AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
+ RANLIB_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ranlib
+ run: |
+ set -euo pipefail
+ cargo build -p elephc
+ cargo build $BRIDGE_CRATES_WINDOWS -p elephc-magician \
+ --target x86_64-pc-windows-gnu
+ mkdir -p target/debug
+ cp target/x86_64-pc-windows-gnu/debug/libelephc_*.a target/debug/
+
+ - name: Run native Windows codegen shard
+ shell: bash
+ run: |
+ cargo nextest run --profile ci --test codegen_tests \
+ --partition hash:${{ matrix.shard }}/8 \
+ --no-fail-fast \
+ -j 2
+
+ - name: Verify shard JUnit report
+ if: always()
+ shell: bash
+ run: |
+ test -s target/nextest/ci/junit.xml
+ grep -q ' target/nextest/ci/nextest-list.json
+
+ - name: Upload shard JUnit report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-codegen-junit-${{ matrix.shard }}
+ path: target/nextest/ci/junit.xml
+ if-no-files-found: error
+ retention-days: 7
+
+ - name: Upload runnable codegen inventory
+ if: always() && matrix.shard == 1
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-codegen-runnable-inventory
+ path: target/nextest/ci/nextest-list.json
+ if-no-files-found: error
+ retention-days: 7
+
+ # Aggregate the native reports independently of each matrix job's exit status.
+ # This proves both strict success and exact one-time coverage, so a missing or
+ # truncated shard cannot masquerade as a green Windows result.
+ windows-codegen-gate:
+ name: Windows Native Codegen Gate
+ runs-on: ubuntu-latest
+ needs:
+ - windows-codegen-native
+ if: always()
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Download shard JUnit reports
+ uses: actions/download-artifact@v4
+ with:
+ pattern: windows-codegen-junit-*
+ path: artifacts/junit
+
+ - name: Download runnable codegen inventory
+ uses: actions/download-artifact@v4
+ with:
+ name: windows-codegen-runnable-inventory
+ path: artifacts/inventory
+
+ - name: Test the coverage verifier itself
+ # This job's verdict is only as trustworthy as the script producing it, and
+ # the script's own regression tests -- including the case proving
+ # `--require-success` actually rejects a failing testcase -- were never run
+ # anywhere in CI. Run them before trusting the verifier, the same way
+ # `test_check_bridge_exports.py` guards its sibling.
+ run: python3 scripts/tests/test_gen_windows_codegen_allowlist.py
+
+ - name: Verify complete Windows codegen coverage
+ shell: bash
+ run: |
+ junit_args=()
+ while IFS= read -r report; do
+ junit_args+=(--junit "$report")
+ done < <(find artifacts/junit -name junit.xml -type f | sort)
+ python3 scripts/gen_windows_codegen_allowlist.py verify-complete \
+ --list-json artifacts/inventory/nextest-list.json \
+ "${junit_args[@]}" \
+ --expected-junit-count 8 \
+ --require-success
+
+ - name: Verify native shard jobs
+ run: |
+ echo "windows-codegen-native result: ${{ needs.windows-codegen-native.result }}"
+ test "${{ needs.windows-codegen-native.result }}" = "success"
diff --git a/AGENTS.md b/AGENTS.md
index 5c69741742..144491a1d2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@ Before contributing, read `CONTRIBUTING.md` in full. It holds the complete step-
## Supported target policy
-All supported targets are first-class targets. The supported target matrix is currently `macos-aarch64`, `linux-aarch64`, and `linux-x86_64`.
+All supported targets are first-class targets. The supported target matrix is currently `macos-aarch64`, `linux-aarch64`, and `linux-x86_64`. `windows-x86_64` is an experimental PE/MinGW target with dedicated native/Wine execution and a strict 8-shard native codegen gate. Promotion requires one same-revision green native gate plus the target-policy review documented in `docs/compiling/targets.md`; the legacy Wine allow-list files are historical snapshots, not acceptance baselines.
Do not design or land codegen/runtime features as ARM64-first with x86_64 treated as a later port. New features, builtins, runtime helpers, optimizer assumptions that affect emitted code, ABI behavior, and ownership/GC paths must either support every supported target in the same change or clearly isolate an intentionally unsupported path with diagnostics, tests, and documentation. A feature is not considered done while any supported target has a missing runtime symbol, reduced semantics, stale documentation, or an untested target-specific lowering path.
@@ -424,6 +424,49 @@ Adding or updating function docblocks must not change code behavior. Do not alte
- **Labels**: use `ctx.next_label("prefix")` — global counter prevents collisions across functions
- **Mixed values**: `PhpType::Mixed` is an internal boxed runtime shape used for heterogeneous associative-array values; codegen/runtime must preserve the boxed cell contract instead of treating it like a plain scalar
+### Windows x86_64 (MSx64) ABI reference
+
+Generated Windows functions and C-facing calls use the Microsoft x64 ABI. Much
+of the handwritten x86_64 runtime retains its internal SysV-shaped convention,
+so calls crossing that boundary use per-symbol shims under
+`src/codegen_support/runtime/win32/`. Rust bridge calls and published bridge
+function pointers must go through the centralized adapters in
+`src/codegen_support/emit.rs`; do not call them as if they were internal runtime
+symbols.
+When writing or reviewing a Win32 shim, hold these rules:
+
+- **Integer args**: `rcx`, `rdx`, `r8`, `r9`, then the stack. Every call reserves a
+ 32-byte **shadow space** the callee owns; the 5th and later integer args go at
+ `[rsp+32]`, `[rsp+40]`, … (above the shadow), never in more registers.
+- **Callee-saved**: `rbx`, `rbp`, `rdi`, `rsi`, `r12`–`r15`. Note `rsi`/`rdi` are
+ **non-volatile** on MSx64 (unlike SysV), so a shim can stage the incoming SysV
+ path/buffer pointer in `rsi`/`rdi` and rely on it surviving every nested Win32 call.
+- **Stack alignment**: a shim is entered at `rsp ≡ 8 (mod 16)` (the `call` pushed the
+ return address). Re-align with `sub rsp, N` where `N ≡ 8 (mod 16)` — i.e. **40 or 56**,
+ not 32 — so `rsp ≡ 0 (mod 16)` at the nested `call`. The unit test
+ `test_stack_alignment_16_bytes` enforces this; the only legitimate `sub rsp, 32` is
+ the exit shim, which first forces alignment with `and rsp, -16`.
+- **Struct / out-param layout is UPWARD (C layout)**: the pointer you pass to an API is
+ the struct's **lowest** address, and a field at byte offset `F` lives at `base + F` —
+ higher offset ⇒ higher address ⇒ *less-negative* `rbp`/`rsp` offset. Never lay a struct
+ downward (`base − F`); a downward layout is invisible to the macOS/Linux tests and only
+ fails under wine. Canonical reference: the `pselect6` fd_set shim (`fd_count@base+0`,
+ `fd_array@base+8`). Zero a struct fully before filling it. This class of bug bit us on
+ the proc_open `STARTUPINFOA`/`PROCESS_INFORMATION` layout and on the `statfs`/`utsname`/
+ `FILETIME`/`BY_HANDLE_FILE_INFORMATION` fills.
+- **Status-convention translation**: many Win32 APIs return a `BOOL` (nonzero = success).
+ A shim standing in for a POSIX C symbol whose consumer tests `== 0` for success
+ (`link`, `rename`, …) **must** translate the `BOOL` to POSIX (`0` = success,
+ `-1` = failure) inside the shim, or success and failure are reported inverted. Mirror
+ the `link`/`rename` shims: `test eax, eax; jz .Lfail; xor rax, rax` / `.Lfail: mov rax, -1`.
+- **32-bit int-status sign extension (Class-3)**: a shim returning a 32-bit C `int` status
+ that a consumer sign-tests must `cdqe` before returning, so a negative status is not read
+ as a large positive `rax`.
+- **Adding a shim**: declare the Win32 import in `WIN32_IMPORTS`, add the `emit_shim_*` and
+ its call in `emit_win32_shims`, and keep non-Windows emitters byte-identical (only the
+ Windows arm and `WIN32_IMPORTS` change). `ntdll`-only APIs (e.g. `RtlGetVersion`) are
+ **not** in the link set — do not import them; use a documented fallback instead.
+
### Assembly comment policy
Every `emitter.instruction(...)` call must have an inline `//` comment aligned to
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1614cdd2ba..1bc7a7add6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ Releases are listed newest first.
- Added a live per-phase spinner, cargo-style bridge-library "Linking" lines, colorized errors/warnings/success output, and a `--timings` report with per-phase percentages — all shown only on an interactive terminal and skippable with the new `--quiet`/`-q` flag, which reproduces the previous plain output exactly.
- Added a comprehensive `-h`/`--help` flag listing every CLI option by category, and made every parameter error (unknown flag, invalid value, missing source file) print a consistent `error: ` plus short usage and a `--help` hint, replacing the previous inconsistent mix of a bare message or a single dense usage line.
- Fixed `implode()` over an indexed array held in a boxed `mixed` cell leaking one heap block per string element (issue #601). Each boxed element is stringified through the persisting mixed-string cast, which allocates an owned copy for string payloads; implode now releases that copy after copying its bytes into the result buffer, on every supported target. Integer, float, and bool elements (which stringify into shared scratch storage) and typed non-mixed string arrays (whose elements are borrowed) are unaffected, and the joined output remains valid.
+- Extended `--web` to the experimental Windows x86_64 PE backend. Unix keeps its prefork supervisor, while Windows uses a single-process current-thread event loop with Ctrl-C shutdown, serialized PHP execution, watchdog termination, and clean `--max-requests` exit. Dedicated PE tests verify the web bridge exports and real `200`/`400` HTTP responses under Wine and native Windows.
## [0.26.2]
- Added experimental PHP `eval()` support across macOS ARM64, Linux ARM64, and Linux x86_64. Eligible literal fragments are parsed at compile time and lowered to native EIR, including direct or scope-backed caller-local synchronization; dynamic strings and unsupported literal shapes fall back to the optional statically linked `elephc-magician` EvalIR interpreter. Within the supported eval subset, the fallback preserves caller/global scope updates, dynamic functions/classes/constants, callables, reflection, builtins, exceptions, ownership/COW behavior, and PHP-visible diagnostics without requiring PHP or the Zend Engine. Bridge linking is automatic when required and can be forced with `--with-eval`; generated builtin documentation now reports AOT and eval availability separately.
@@ -75,7 +76,7 @@ Releases are listed newest first.
- Deprecated `${var}` / `${expr}` string interpolation is now accepted by the lexer (issue #340), matching PHP 8.x's deprecated-but-working behavior.
- `var_dump()` is now variadic (issue #389): each argument is dumped independently in source order. `print_r()` gains the `$return` flag — `print_r($v, true)` returns the rendered string instead of echoing, including when the flag is only known at runtime (`string|bool` boxed result); captures are truncated at the 64 KiB buffer cap.
- Reference aliases to indexed-array elements (issue #331): `$b =& $a[0]` binds a local to the element's storage with write-through in both directions, on every supported target. Associative arrays and out-of-range autovivification are documented limits.
-- Windows groundwork (issue #379): `windows-x86_64` is parsed as a target and every platform dispatch has an explicit "not yet supported" diagnostic instead of an exhaustiveness gap.
+- Windows groundwork (issue #379): `windows-x86_64` target parsing and explicit platform dispatch diagnostics landed as the foundation later extended by the experimental PE32+/MinGW backend described below.
- Source maps v2: `--source-map` now writes a versioned machine-readable schema (`format: "elephc-source-map"`, `version: 2`) with function ranges (PHP name, entry symbol, assembly line range, synthetic flag for compiler-generated bodies), assembly labels attributed to their owning function and EIR basic block, instruction mappings tagged with the originating EIR opcode, expression end positions, and optimization provenance (`const_fold`/`licm`), plus a PHP-line → assembly-range inverse index and a `source_sha256` staleness checksum. The schema contract for external tooling is documented in `docs/compiling/source-maps.md`; the flat v1 `entries` format is superseded.
- New `--debug-info` flag: embeds DWARF debug information in the generated assembly — a `.file`/`.loc` line table plus a compile unit with one `DW_TAG_subprogram` per PHP function, derived from the same source markers as `--source-map` — so lldb/gdb breakpoints (`b file.php:3`) and profiler samples resolve to PHP source lines without custom tooling. On macOS the pipeline runs `dsymutil` to produce a `.dSYM` next to the binary (keeping the object file as a fallback when that fails); on Linux the line tables link directly into the binary.
- Expression spans now carry end positions: the lexer records token extents and the parser widens binary, assignment, and call spans through their last token, keeping the start anchored so diagnostics are unchanged.
@@ -89,6 +90,8 @@ Releases are listed newest first.
`true` uses strict type-identical membership.
- Added `mb_ereg_match()`: a PCRE2-backed, start-anchored mbregex builtin with
the optional `$options` argument and support for `i` case-insensitive matching.
+- Add the initial experimental Windows x86_64 (PE32+) cross-compilation target (`--target windows-x86_64`, alias `x86_64-pc-windows-gnu`): at introduction it produced a GNU/MinGW-ABI `.exe` (`.dll` for `--emit cdylib`) via `x86_64-w64-mingw32-gcc`, while runtime shims and execution coverage were still catching up. The Unreleased entries above describe the subsequently expanded PE, bridge, eval, web, native-Windows, Wine, and optional LLVM/LLD coverage; the target remains experimental until the same revision passes the strict native gate and target-policy review.
+- Add the `random_bytes(int $length): string` builtin: a cryptographically secure random byte string on every supported target (arc4random_buf / getrandom / BCryptGenRandom), fatal on entropy failure or a length below 1.
- Int-backed enum `from()` / `tryFrom()` now accept a dynamically-typed (`mixed`) argument (issue #449): a `foreach` value over a heterogeneous array, an untyped parameter, etc. are coerced on their runtime type before the enum lookup — integer/numeric-string resolve (or throw `ValueError`), float truncates, bool/null coerce, and array/object/resource/closure throw `TypeError` naming the given type. Previously any `mixed` argument was rejected at compile time. Target-aware on every supported backend.
- Int-backed enum `from()` / `tryFrom()` now accept a numeric string (issue #349): `Level::from("1")` coerces the string to the integer backing value (as a distinct EIR coercion lowered before the enum call) and returns the matching case, instead of being rejected at compile time. A numeric string with no matching case throws `ValueError`; a non-numeric string (e.g. `"x"`) throws `TypeError` with PHP's exact argument-type message — matching PHP's coercive typing on every supported target, including PHP-rejected libc `strtod` extensions such as hexadecimal `"0x1"`, `"INF"`, and `"NAN"`.
- Fixed an enum `from()` / `tryFrom()` refcount bug (surfaced while fixing #349): the returned case singleton was under-retained, so storing the result into a reassigned variable inside a loop drove the persistent singleton's refcount to zero and freed it — producing garbage reads or a heap crash after a few iterations. `from()`/`tryFrom()` now retain the matched singleton, keeping it alive like direct case access. Affected both backed-enum backings.
diff --git a/Cargo.lock b/Cargo.lock
index 9c4e5a475d..ab7ae6859b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -171,6 +171,15 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
[[package]]
name = "borsh"
version = "1.6.1"
@@ -395,6 +404,16 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -518,6 +537,17 @@ dependencies = [
"hybrid-array",
]
+[[package]]
+name = "ctrlc"
+version = "3.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
+dependencies = [
+ "dispatch2",
+ "nix",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "ctutils"
version = "0.4.2"
@@ -572,6 +602,18 @@ dependencies = [
"ctutils",
]
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.0",
+ "block2",
+ "libc",
+ "objc2",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.6"
@@ -648,6 +690,7 @@ dependencies = [
"elephc-crypto",
"elephc-phar",
"flate2",
+ "getrandom 0.2.17",
"inventory",
"libc",
"unicode-segmentation",
@@ -673,6 +716,7 @@ dependencies = [
"bzip2",
"bzip2-rs",
"flate2",
+ "getrandom 0.2.17",
"md-5 0.10.6",
"rsa",
"sha1",
@@ -685,6 +729,7 @@ version = "0.1.0"
dependencies = [
"libc",
"rustls",
+ "rustls-native-certs",
"rustls-pemfile",
"webpki-roots 0.26.11",
]
@@ -697,6 +742,7 @@ version = "0.1.0"
name = "elephc-web"
version = "0.1.0"
dependencies = [
+ "ctrlc",
"flate2",
"http",
"http-body-util",
@@ -705,6 +751,7 @@ dependencies = [
"libc",
"socket2 0.5.10",
"tokio",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1514,6 +1561,18 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nom"
version = "7.1.3"
@@ -1586,6 +1645,15 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -1595,6 +1663,12 @@ dependencies = [
"bitflags 2.13.0",
]
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
[[package]]
name = "objc2-system-configuration"
version = "0.3.2"
@@ -1619,6 +1693,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2119,6 +2199,18 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
@@ -2160,6 +2252,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2172,6 +2273,29 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "semver"
version = "1.0.28"
diff --git a/README.md b/README.md
index ab43d9a6b3..1053073430 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,11 @@
- 3 native targets · no Zend Engine · no external PHP runtime · single standalone binary
+ 4 native targets · no Zend Engine · no external PHP runtime · single standalone binary
- A PHP-to-native compiler that takes a subset of PHP and compiles it directly to native assembly, producing standalone binaries for macOS ARM64, Linux ARM64, and Linux x86_64. Ordinary source is AOT-compiled with no opcode fallback; experimental eval() can embed an optional interpreter bridge when runtime parsing is required.
+ A PHP-to-native compiler that takes a subset of PHP and compiles it directly to native assembly, producing standalone binaries for macOS ARM64, Linux ARM64, Linux x86_64, and an experimental Windows x86_64 cross-compilation target. Ordinary source is AOT-compiled with no opcode fallback; experimental eval() can embed an optional interpreter bridge when runtime parsing is required.
@@ -86,7 +86,7 @@ I made the project as modular as possible. Every function has its own codegen fi
## What you can expect
-You can write PHP using the constructs documented in the [docs](docs/). Classes with single inheritance, interfaces, `instanceof`, nullsafe access (`?->`), abstract classes, final classes, methods and typed/static properties, PHP-style static property redeclarations, constructor property promotion, traits, constructors, instance/static methods, case-insensitive PHP symbol lookup for functions/classes/methods, `self::` / `parent::` / `static::` with late static binding, `readonly` properties and classes, enums, PHP 8 attributes on declarations, named arguments, first-class callables, typed function and method parameters and returns, `try` / `catch` / `finally` / `throw`, visibility modifiers, union and nullable types, copy-on-write arrays, associative arrays with PHP insertion order and integer/numeric-string key normalization, array union with `+`, closures, generator functions and generator closures with `yield` / `yield from`, namespaces, includes, compile-time Composer/SPL autoloading, class/introspection helpers, `PDO` database access (`PDO` / `PDOStatement` / `PDOException`) with SQLite, PostgreSQL, and MySQL/MariaDB drivers, image creation and manipulation (GD raster I/O, drawing, transforms/filters, Exif/IPTC metadata, and the `Imagick`/`Gmagick`/Cairo object APIs) on a pure-Rust codec/raster bridge, and PHP 8.1-style `Fiber` coroutines on macOS ARM64, Linux ARM64, and Linux x86_64.
+You can write PHP using the constructs documented in the [docs](docs/). Classes with single inheritance, interfaces, `instanceof`, nullsafe access (`?->`), abstract classes, final classes, methods and typed/static properties, PHP-style static property redeclarations, constructor property promotion, traits, constructors, instance/static methods, case-insensitive PHP symbol lookup for functions/classes/methods, `self::` / `parent::` / `static::` with late static binding, `readonly` properties and classes, enums, PHP 8 attributes on declarations, named arguments, first-class callables, typed function and method parameters and returns, `try` / `catch` / `finally` / `throw`, visibility modifiers, union and nullable types, copy-on-write arrays, associative arrays with PHP insertion order and integer/numeric-string key normalization, array union with `+`, closures, generator functions and generator closures with `yield` / `yield from`, namespaces, includes, compile-time Composer/SPL autoloading, class/introspection helpers, `PDO` database access (`PDO` / `PDOStatement` / `PDOException`) with SQLite, PostgreSQL, and MySQL/MariaDB drivers, image creation and manipulation (GD raster I/O, drawing, transforms/filters, Exif/IPTC metadata, and the `Imagick`/`Gmagick`/Cairo object APIs) on a pure-Rust codec/raster bridge, and PHP 8.1-style `Fiber` coroutines on macOS ARM64, Linux ARM64, Linux x86_64, and the experimental Windows x86_64 backend.
Experimental [`eval()` support](docs/php/eval.md) AOT-lowers eligible literal fragments and falls back to the optional, statically linked Magician interpreter for dynamic fragments. Runnable examples live in [`examples/eval/`](examples/eval/) and [`examples/eval-globals/`](examples/eval-globals/).
@@ -206,11 +206,12 @@ elephc app.php --with-pdo --with-crypto
elephc app.php --with-eval
# Explicit target selection
-# Supported targets today: macos-aarch64, linux-aarch64, linux-x86_64
+# Supported targets today: macos-aarch64, linux-aarch64, linux-x86_64, windows-x86_64 (experimental)
elephc --target linux-aarch64 hello.php
elephc --target linux-x86_64 hello.php
+elephc --target windows-x86_64 hello.php # experimental; MinGW ABI/sysroot (GNU default, LLVM optional)
-# Compile a standalone prefork HTTP server binary
+# Compile a standalone HTTP server (Unix prefork; Windows event loop)
elephc --web app.php
./app --listen 127.0.0.1:8080
./app --listen 0.0.0.0:8080 --workers 4
@@ -326,7 +327,7 @@ The full list of supported constructs, operators, and control structures is in t
- **OOP**: classes, abstract/final classes, typed/final/static properties and methods, PHP-style static property redeclarations, direct static array property writes, constructor property promotion, interfaces, `instanceof`, traits, enums, PHP 8 declaration attributes, limited attribute reflection (`ReflectionClass`/`ReflectionMethod`/`ReflectionProperty::getAttributes()`, `ReflectionAttribute::newInstance()`), `readonly`, static/instance methods, case-insensitive class/interface/trait and method lookup, `self::`/`parent::`/`static::`, `::class` reflection (including `$object::class` on object expressions, returning the receiver's runtime class), class constants including PHP 8.3 typed class constants (exposed via `ReflectionClassConstant::hasType()`/`getType()`), `new self()` / `new static()` / `new parent()`, magic methods (`__toString`, `__get`, `__set`)
- **Functions**: case-insensitive user and built-in function calls, default parameters, variadic/spread, pass by reference, named arguments, global variables, static locals, first-class callables, closures, arrow functions, static closures (`static function () { }`, `static fn () => ...`)
- **Generators**: generator functions and closures, `yield`, key/value yields, `yield from`, `Generator::send()`, `throw()`, `getReturn()`, and `foreach` over `Iterator` / `IteratorAggregate`
-- **Fibers**: `Fiber`, `FiberError`, `Fiber::suspend()`, `Fiber::getCurrent()`, `start()`, `resume()`, `throw()`, `getReturn()`, state predicates, closure captures, guarded native stacks, and target-aware context switching on macOS ARM64, Linux ARM64, and Linux x86_64
+- **Fibers**: `Fiber`, `FiberError`, `Fiber::suspend()`, `Fiber::getCurrent()`, `start()`, `resume()`, `throw()`, `getReturn()`, state predicates, closure captures, guarded native stacks, and target-aware context switching on macOS ARM64, Linux ARM64, Linux x86_64, and the experimental Windows x86_64 backend
- **Control flow**: if/elseif/else, while, do-while, for, foreach, switch, match, break/continue including multi-level depths, try/catch/finally/throw
- **Statements and literals**: `const` / `define()` constants, `global` declarations, `static` locals (with or without an initializer), `print` expressions, list unpacking, PHP numeric literal forms, heredoc / nowdoc strings, `declare(strict_types=1)` and `declare(ticks=...)` directives (validated syntactically and treated as no-ops — elephc compiles an always-strict subset)
- **Operators**: arithmetic, comparison, `instanceof`, logical, bitwise, ternary, null coalescing (`??`), PHP 8.5 pipe (`|>`), assignment expressions for local and stabilized non-local targets, null coalescing assignment (`??=`), error control (`@`), and compound assignments
diff --git a/ROADMAP.md b/ROADMAP.md
index fc82ea6175..8ea7e0102c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -957,6 +957,8 @@ and 0.x validation rather than by speculative pass work.
- [ ] Composite conditional include function variants — extend include-graph exclusivity from one direct `if` / `elseif` / `else` chain to nested/composed conditional paths where declarations are pairwise exclusive only after combining multiple branch decisions
- [ ] Switch-aware conditional include function variants — extend include-graph exclusivity beyond `if` / `elseif` / `else` to `switch` cases once fall-through, `break`, and terminating case bodies are modeled precisely; revisit `match` only if include-like statement lowering ever appears inside match arms
- [x] Runtime routine dead stripping — include or link only runtime helpers reachable from the generated program instead of carrying the whole target runtime slice
+- [x] Windows x86_64 (PE32+) cross-compilation target (experimental, newly added) — `--target windows-x86_64` (alias `x86_64-pc-windows-gnu`) cross-compiles to a GNU/MinGW-ABI binary via `x86_64-w64-mingw32-gcc` (`msvcrt`), producing `.exe` (`.dll` for `--emit cdylib`); requires the MinGW-w64 cross toolchain (`x86_64-w64-mingw32-as`, `x86_64-w64-mingw32-gcc`) on the host doing the build. CI cross-compiles and validates PE32+ structure (assemble + link) with MinGW-w64, and additionally executes the cross-compiled binaries under Wine (`wine64`/`wine`) to assert stdout for echo, arithmetic, string concatenation, loops, and function calls — closing the prior compile-only testing gap; broader runtime shim coverage (files, sockets, process control, …) is still not at parity with macOS/Linux.
+- [x] `random_bytes(int $length): string` — cryptographically secure random byte string on every supported target (arc4random_buf / getrandom / BCryptGenRandom), fatal on entropy failure or length below 1
- [x] Statically-known catchable `Error` conditions (issue #383) — private/protected method access from an inaccessible scope and readonly property writes outside the declaring constructor raise a catchable `Error` at runtime instead of being rejected at compile time, matching PHP
- [ ] Tail-call optimization — direct tail self- and mutual-recursion lowering on top of EIR (`Br` to function entry with parameter rebinding)
- [ ] Performance within 2x of C -O0 on compute benchmarks
diff --git a/crates/elephc-crypto/src/lib.rs b/crates/elephc-crypto/src/lib.rs
index c18cc48dc6..f0a38118a9 100644
--- a/crates/elephc-crypto/src/lib.rs
+++ b/crates/elephc-crypto/src/lib.rs
@@ -107,17 +107,17 @@ impl HashCtx {
}
/// Finalizes the context into its raw digest, consuming it.
- fn finalize(self) -> Vec {
+ fn finalize(self) -> Option> {
match self {
- HashCtx::Plain(s) => s.finalize_box(),
+ HashCtx::Plain(s) => Some(s.finalize_box()),
HashCtx::Hmac { algo, opad_material, inner } => {
let inner_digest = inner.finalize_box();
- // constructively unreachable: make() is a static table and this algo was accepted
- // just above; note a panic here would abort across the extern "C" boundary.
- let mut outer = make(&algo).expect("hmac algo was validated at init");
+ // Keep the FFI boundary fallible even if the static algorithm table and
+ // HMAC block-size table accidentally drift out of sync in the future.
+ let mut outer = make(&algo)?;
outer.update(&opad_material);
outer.update(&inner_digest);
- outer.finalize_box()
+ Some(outer.finalize_box())
}
}
}
@@ -169,9 +169,11 @@ pub unsafe extern "C" fn elephc_crypto_init_hmac(
};
let ipad: Vec = k.iter().map(|b| b ^ 0x36).collect();
let opad_material: Vec = k.iter().map(|b| b ^ 0x5c).collect();
- // constructively unreachable: make() is a static table and this algo was accepted
- // just above; note a panic here would abort across the extern "C" boundary.
- let mut inner = make(&name).expect("algo validated by block_key");
+ // Treat registry drift as a normal bridge failure instead of panicking across C ABI.
+ let mut inner = match make(&name) {
+ Some(inner) => inner,
+ None => return std::ptr::null_mut(),
+ };
inner.update(&ipad);
Box::into_raw(Box::new(HashCtx::Hmac { algo: name, opad_material, inner })) as *mut c_void
}
@@ -214,7 +216,10 @@ pub unsafe extern "C" fn elephc_crypto_final(ctx: *mut c_void, out_ptr: *mut u8)
return -1;
}
let ctx = &*(ctx as *mut HashCtx);
- let digest = ctx.clone_box().finalize();
+ let digest = match ctx.clone_box().finalize() {
+ Some(digest) => digest,
+ None => return -1,
+ };
std::ptr::copy_nonoverlapping(digest.as_ptr(), out_ptr, digest.len());
digest.len() as isize
}
diff --git a/crates/elephc-image/src/cairo/context.rs b/crates/elephc-image/src/cairo/context.rs
index 15a2367f60..f3b48c6205 100644
--- a/crates/elephc-image/src/cairo/context.rs
+++ b/crates/elephc-image/src/cairo/context.rs
@@ -416,9 +416,7 @@ pub extern "C" fn elephc_cairo_identity_matrix(ctx: i64) {
#[no_mangle]
pub extern "C" fn elephc_cairo_get_current_point_x(ctx: i64) -> i64 {
ffi_guard(-1, move || {
- contexts()
- .lock()
- .unwrap()
+ lock_recover(contexts())
.get(&ctx)
.map_or(0, |c| (c.cur.x * 1000.0).round() as i64)
})
@@ -428,9 +426,7 @@ pub extern "C" fn elephc_cairo_get_current_point_x(ctx: i64) -> i64 {
#[no_mangle]
pub extern "C" fn elephc_cairo_get_current_point_y(ctx: i64) -> i64 {
ffi_guard(-1, move || {
- contexts()
- .lock()
- .unwrap()
+ lock_recover(contexts())
.get(&ctx)
.map_or(0, |c| (c.cur.y * 1000.0).round() as i64)
})
diff --git a/crates/elephc-image/src/cairo/surface.rs b/crates/elephc-image/src/cairo/surface.rs
index 76288d3830..e68eeff692 100644
--- a/crates/elephc-image/src/cairo/surface.rs
+++ b/crates/elephc-image/src/cairo/surface.rs
@@ -49,9 +49,7 @@ pub extern "C" fn elephc_cairo_surface_destroy(s: i64) {
#[no_mangle]
pub extern "C" fn elephc_cairo_surface_width(s: i64) -> i64 {
ffi_guard(-1, move || {
- surfaces()
- .lock()
- .unwrap()
+ lock_recover(surfaces())
.get(&s)
.map_or(-1, |pm| pm.width() as i64)
})
@@ -61,9 +59,7 @@ pub extern "C" fn elephc_cairo_surface_width(s: i64) -> i64 {
#[no_mangle]
pub extern "C" fn elephc_cairo_surface_height(s: i64) -> i64 {
ffi_guard(-1, move || {
- surfaces()
- .lock()
- .unwrap()
+ lock_recover(surfaces())
.get(&s)
.map_or(-1, |pm| pm.height() as i64)
})
diff --git a/crates/elephc-image/src/codec.rs b/crates/elephc-image/src/codec.rs
index 632d625a76..7671dacb3a 100644
--- a/crates/elephc-image/src/codec.rs
+++ b/crates/elephc-image/src/codec.rs
@@ -128,9 +128,15 @@ pub extern "C" fn elephc_img_stage_ptr(len: i64) -> *mut u8 {
if len <= 0 {
return std::ptr::null_mut();
}
+ let Ok(len) = usize::try_from(len) else {
+ return std::ptr::null_mut();
+ };
let mut guard = lock_recover(stage_cell());
guard.clear();
- guard.resize(len as usize, 0);
+ if guard.try_reserve_exact(len).is_err() {
+ return std::ptr::null_mut();
+ }
+ guard.resize(len, 0);
guard.as_mut_ptr()
})
}
diff --git a/crates/elephc-image/src/draw.rs b/crates/elephc-image/src/draw.rs
index f0e1242e0b..e43ceee982 100644
--- a/crates/elephc-image/src/draw.rs
+++ b/crates/elephc-image/src/draw.rs
@@ -296,8 +296,12 @@ fn fill_polygon(img: &mut RgbaImage, blending: bool, pts: &[(i64, i64)], src: Rg
if pts.len() < 3 {
return;
}
- let ymin = pts.iter().map(|p| p.1).min().unwrap();
- let ymax = pts.iter().map(|p| p.1).max().unwrap();
+ let Some(ymin) = pts.iter().map(|p| p.1).min() else {
+ return;
+ };
+ let Some(ymax) = pts.iter().map(|p| p.1).max() else {
+ return;
+ };
let n = pts.len();
for y in ymin..=ymax {
let mut xs: Vec = Vec::new();
diff --git a/crates/elephc-image/src/imagick.rs b/crates/elephc-image/src/imagick.rs
index 6d986834ac..8ea8f617b8 100644
--- a/crates/elephc-image/src/imagick.rs
+++ b/crates/elephc-image/src/imagick.rs
@@ -274,9 +274,7 @@ pub extern "C" fn elephc_imagick_add_image(dst_wand: i64, src_wand: i64) -> i64
let Some(src_handle) = current_handle(src_wand) else {
return -1;
};
- let src_fmt = wands()
- .lock()
- .unwrap()
+ let src_fmt = lock_recover(wands())
.get(&src_wand)
.map(|w| w.format)
.unwrap_or(FMT_PNG);
diff --git a/crates/elephc-image/src/lib.rs b/crates/elephc-image/src/lib.rs
index 46aebf3368..9b21bd2519 100644
--- a/crates/elephc-image/src/lib.rs
+++ b/crates/elephc-image/src/lib.rs
@@ -285,3 +285,108 @@ pub(crate) fn pack_color(pixel: Rgba) -> i64 {
let gd_alpha = ((255 - a as u32) * 127 / 255) as i64;
(gd_alpha << 24) | ((r as i64) << 16) | ((g as i64) << 8) | b as i64
}
+
+#[cfg(test)]
+mod tests {
+ use std::ffi::CString;
+
+ use super::*;
+
+ /// All statically enabled raster codecs encode and decode a small image
+ /// through the same staging-buffer ABI used by generated PHP programs.
+ #[test]
+ fn static_codec_round_trip_matrix() {
+ let handle = gd::elephc_img_create_truecolor(3, 2);
+ assert!(handle > 0);
+ gd::elephc_img_set_pixel(handle, 1, 1, 0x00_33_66_cc);
+
+ for format in [FMT_PNG, FMT_JPEG, FMT_GIF, FMT_BMP, FMT_WEBP] {
+ assert_eq!(codec::elephc_img_encode(handle, format, 85), 0);
+ let len = codec::elephc_img_encoded_len();
+ assert!(len > 0, "format {format} produced no bytes");
+ let stage = codec::elephc_img_stage_ptr(len);
+ assert!(!stage.is_null());
+ unsafe {
+ std::ptr::copy_nonoverlapping(
+ codec::elephc_img_encoded_ptr(),
+ stage,
+ len as usize,
+ );
+ }
+ let decoded = codec::elephc_img_create_from_stage(len);
+ assert!(decoded > 0, "format {format} did not decode");
+ assert_eq!(gd::elephc_img_sx(decoded), 3);
+ assert_eq!(gd::elephc_img_sy(decoded), 2);
+ gd::elephc_img_destroy(decoded);
+ codec::elephc_img_encoded_clear();
+ }
+ gd::elephc_img_destroy(handle);
+ }
+
+ /// File-backed PNG I/O preserves a non-ASCII path and image geometry,
+ /// exercising Rust's UTF-8-to-native-path conversion used on Windows.
+ #[test]
+ fn unicode_file_path_round_trip() {
+ let root = std::env::temp_dir().join(format!(
+ "elephc-image-Données-日本語-{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).expect("create Unicode image directory");
+ let path = root.join("résultat-東京.png");
+ let c_path = CString::new(path.to_string_lossy().as_bytes()).expect("valid image path");
+
+ let handle = gd::elephc_img_create_truecolor(4, 3);
+ assert!(handle > 0);
+ assert_eq!(
+ unsafe { codec::elephc_img_write_file(handle, FMT_PNG, c_path.as_ptr(), -1) },
+ 0
+ );
+ let decoded = unsafe { codec::elephc_img_create_from_file(c_path.as_ptr(), FMT_PNG) };
+ assert!(decoded > 0);
+ assert_eq!((gd::elephc_img_sx(decoded), gd::elephc_img_sy(decoded)), (4, 3));
+
+ let tga_path = root.join("résultat-東京.tga");
+ let c_tga = CString::new(tga_path.to_string_lossy().as_bytes()).expect("valid TGA path");
+ assert_eq!(
+ unsafe { codec::elephc_img_write_file(handle, FMT_TGA, c_tga.as_ptr(), -1) },
+ 0
+ );
+ let decoded_tga =
+ unsafe { codec::elephc_img_create_from_file(c_tga.as_ptr(), FMT_TGA) };
+ assert!(decoded_tga > 0);
+ assert_eq!(
+ (gd::elephc_img_sx(decoded_tga), gd::elephc_img_sy(decoded_tga)),
+ (4, 3)
+ );
+
+ gd::elephc_img_destroy(handle);
+ gd::elephc_img_destroy(decoded);
+ gd::elephc_img_destroy(decoded_tga);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ /// Oversized ABI buffer requests fail with null instead of overflowing a
+ /// length conversion or attempting an aborting allocation.
+ #[test]
+ fn oversized_transfer_buffers_fail_cleanly() {
+ assert!(codec::elephc_img_stage_ptr(i64::MAX).is_null());
+ assert!(xfer::elephc_img_in_ptr(i64::MAX).is_null());
+ }
+
+ /// Bundled bitmap text renders a Latin-1 accented glyph without consulting
+ /// an OS font installation or a platform-specific font path.
+ #[test]
+ fn bundled_unicode_bitmap_font_renders_accented_glyph() {
+ let handle = gd::elephc_img_create_truecolor(8, 8);
+ let text = CString::new("é").expect("valid Unicode test text");
+ unsafe { text::elephc_img_string(handle, 1, 0, 0, 0x00_ff_00_00, text.as_ptr()) };
+ let guard = lock_recover(images());
+ let rendered = guard
+ .get(&handle)
+ .is_some_and(|obj| obj.img.pixels().any(|pixel| pixel.0[0] != 0));
+ drop(guard);
+ gd::elephc_img_destroy(handle);
+ assert!(rendered, "accented glyph was left blank");
+ }
+}
diff --git a/crates/elephc-image/src/text.rs b/crates/elephc-image/src/text.rs
index 5a0f325a7c..b95c178cca 100644
--- a/crates/elephc-image/src/text.rs
+++ b/crates/elephc-image/src/text.rs
@@ -14,6 +14,9 @@
//! - `font8x8` packs each glyph as eight row bytes with the least-significant bit
//! as the leftmost column. Horizontal text advances 8 px per character;
//! `imagestringup` rotates the layout 90° counter-clockwise.
+//! - All bundled Unicode tables are consulted, so Latin accents, Greek,
+//! Hiragana, box drawing, blocks, and miscellaneous symbols render without
+//! filesystem fonts. Unsupported code points remain blank glyphs.
//! - Rendering honors the image's alpha-blending mode via the shared
//! `draw::plot` helper.
@@ -25,7 +28,10 @@ use crate::{ffi_guard, lock_recover, cstr_arg, images, unpack_color};
/// set the layout is rotated 90° counter-clockwise (`imagestringup`). The `font`
/// number is accepted for API parity but does not change the cell size.
fn render_builtin(handle: i64, x: i64, y: i64, color: i64, text: &str, vertical: bool) {
- use font8x8::{UnicodeFonts, BASIC_FONTS};
+ use font8x8::{
+ UnicodeFonts, BASIC_FONTS, BLOCK_FONTS, BOX_FONTS, GREEK_FONTS, HIRAGANA_FONTS,
+ LATIN_FONTS, MISC_FONTS,
+ };
let mut guard = lock_recover(images());
let Some(obj) = guard.get_mut(&handle) else {
@@ -34,7 +40,15 @@ fn render_builtin(handle: i64, x: i64, y: i64, color: i64, text: &str, vertical:
let blending = obj.alpha_blending;
let src = unpack_color(color);
for (index, ch) in text.chars().enumerate() {
- let glyph = BASIC_FONTS.get(ch).unwrap_or([0u8; 8]);
+ let glyph = BASIC_FONTS
+ .get(ch)
+ .or_else(|| LATIN_FONTS.get(ch))
+ .or_else(|| GREEK_FONTS.get(ch))
+ .or_else(|| HIRAGANA_FONTS.get(ch))
+ .or_else(|| BLOCK_FONTS.get(ch))
+ .or_else(|| BOX_FONTS.get(ch))
+ .or_else(|| MISC_FONTS.get(ch))
+ .unwrap_or([0u8; 8]);
let i = index as i64;
for row in 0..8i64 {
let bits = glyph[row as usize];
diff --git a/crates/elephc-image/src/xfer.rs b/crates/elephc-image/src/xfer.rs
index 70108a1f3f..7d972adfe5 100644
--- a/crates/elephc-image/src/xfer.rs
+++ b/crates/elephc-image/src/xfer.rs
@@ -89,9 +89,15 @@ pub extern "C" fn elephc_img_in_ptr(len: i64) -> *mut u8 {
if len <= 0 {
return std::ptr::null_mut();
}
+ let Ok(len) = usize::try_from(len) else {
+ return std::ptr::null_mut();
+ };
let mut guard = lock_recover(in_cell());
guard.clear();
- guard.resize(len as usize, 0);
+ if guard.try_reserve_exact(len).is_err() {
+ return std::ptr::null_mut();
+ }
+ guard.resize(len, 0);
guard.as_mut_ptr()
})
}
diff --git a/crates/elephc-magician/Cargo.toml b/crates/elephc-magician/Cargo.toml
index 0090199bde..4bc49b66e6 100644
--- a/crates/elephc-magician/Cargo.toml
+++ b/crates/elephc-magician/Cargo.toml
@@ -13,6 +13,7 @@ crate-type = ["staticlib", "rlib"]
elephc-crypto = { path = "../elephc-crypto" }
elephc-phar = { path = "../elephc-phar" }
flate2 = "1"
+getrandom = "0.2"
inventory = "0.3"
libc = "0.2"
unicode-segmentation = "1"
diff --git a/crates/elephc-magician/build.rs b/crates/elephc-magician/build.rs
index f50825d9fe..d45e827c21 100644
--- a/crates/elephc-magician/build.rs
+++ b/crates/elephc-magician/build.rs
@@ -9,23 +9,42 @@
//! binaries that link the rlib need the same native libraries as generated
//! elephc binaries.
-use std::{env, path::Path};
+use std::{
+ env,
+ path::{Path, PathBuf},
+};
/// Emits native PCRE2 link directives for cargo-built test binaries and rlibs.
fn main() {
+ println!("cargo:rerun-if-env-changed=ELEPHC_MINGW_SYSROOT");
for path in pcre2_library_search_paths() {
- println!("cargo:rustc-link-search=native={path}");
+ println!("cargo:rustc-link-search=native={}", path.display());
}
println!("cargo:rustc-link-lib=pcre2-posix");
println!("cargo:rustc-link-lib=pcre2-8");
+ if env::var("TARGET").as_deref() == Ok("x86_64-pc-windows-gnu") {
+ println!("cargo:rustc-link-lib=iconv");
+ }
if env::var("TARGET").as_deref() == Ok("aarch64-unknown-linux-musl") {
println!("cargo:rustc-link-lib=gcc");
}
}
-/// Returns common PCRE2 library directories for local macOS/Homebrew builds.
-fn pcre2_library_search_paths() -> Vec<&'static str> {
- [
+/// Returns target-compatible PCRE2 library directories from the MinGW sysroot
+/// and common local macOS/Homebrew installations.
+fn pcre2_library_search_paths() -> Vec {
+ let mut paths = Vec::new();
+ if env::var("TARGET").as_deref() == Ok("x86_64-pc-windows-gnu") {
+ if let Some(sysroot) = env::var_os("ELEPHC_MINGW_SYSROOT") {
+ let sysroot = PathBuf::from(sysroot);
+ for directory in [sysroot.join("lib"), sysroot.join("lib64")] {
+ if directory.is_dir() {
+ paths.push(directory);
+ }
+ }
+ }
+ }
+ paths.extend([
"/opt/homebrew/opt/pcre2/lib",
"/opt/homebrew/lib",
"/usr/local/opt/pcre2/lib",
@@ -33,5 +52,6 @@ fn pcre2_library_search_paths() -> Vec<&'static str> {
]
.into_iter()
.filter(|path| Path::new(path).exists())
- .collect()
+ .map(PathBuf::from));
+ paths
}
diff --git a/crates/elephc-magician/src/context/core.rs b/crates/elephc-magician/src/context/core.rs
index e757874fb1..863eadb491 100644
--- a/crates/elephc-magician/src/context/core.rs
+++ b/crates/elephc-magician/src/context/core.rs
@@ -9,6 +9,25 @@
use super::*;
+/// Stable lookup key for emulated POSIX permission bits on local files.
+#[cfg(any(windows, test))]
+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
+pub(crate) enum LocalFileModeKey {
+ /// Host filesystem identity, shared by every hard-link alias.
+ FileId { volume: u64, index: u64 },
+ /// Canonical, platform-normalized path used when file identity is unavailable.
+ Path(String),
+}
+
+/// Permission metadata captured before a filesystem mutation changes path reachability.
+#[cfg(any(windows, test))]
+#[derive(Clone, Debug)]
+pub(crate) struct LocalFileModeToken {
+ pub(super) key: LocalFileModeKey,
+ pub(super) mode: u32,
+ pub(super) last_link: bool,
+}
+
/// Process-level eval context passed opaquely across the C ABI.
///
/// Generated code never inspects this layout directly; it only passes pointers
@@ -74,6 +93,8 @@ pub struct ElephcEvalContext {
pub(super) pending_throw: Option,
pub(super) spl_autoload_extensions: String,
pub(super) streams: EvalStreamResources,
+ #[cfg(any(windows, test))]
+ pub(super) local_file_modes: HashMap,
pub(super) json_last_error: i64,
pub(super) json_last_error_msg: String,
pub(super) default_timezone: String,
@@ -147,6 +168,8 @@ impl ElephcEvalContext {
pending_throw: None,
spl_autoload_extensions: String::from(".inc,.php"),
streams: EvalStreamResources::default(),
+ #[cfg(any(windows, test))]
+ local_file_modes: HashMap::new(),
json_last_error: 0,
json_last_error_msg: String::from("No error"),
default_timezone: String::from("UTC"),
@@ -221,6 +244,8 @@ impl ElephcEvalContext {
pending_throw: None,
spl_autoload_extensions: String::from(".inc,.php"),
streams: EvalStreamResources::default(),
+ #[cfg(any(windows, test))]
+ local_file_modes: HashMap::new(),
json_last_error: 0,
json_last_error_msg: String::from("No error"),
default_timezone: String::from("UTC"),
diff --git a/crates/elephc-magician/src/context/runtime_state.rs b/crates/elephc-magician/src/context/runtime_state.rs
index 10f4d62111..85d1e884d8 100644
--- a/crates/elephc-magician/src/context/runtime_state.rs
+++ b/crates/elephc-magician/src/context/runtime_state.rs
@@ -111,6 +111,82 @@ impl ElephcEvalContext {
self.included_files.insert(path.into());
}
+ /// Records PHP-visible permission bits against the file's stable identity.
+ #[cfg(any(windows, test))]
+ pub(crate) fn remember_local_file_mode(
+ &mut self,
+ path: impl AsRef,
+ mode: u32,
+ ) {
+ if let Some(key) = local_file_mode_key(path.as_ref()) {
+ self.local_file_modes.insert(key, mode & 0o7777);
+ }
+ }
+
+ /// Returns emulated PHP permission bits for one local file, when present.
+ #[cfg(any(windows, test))]
+ pub(crate) fn local_file_mode(&self, path: impl AsRef) -> Option {
+ let key = local_file_mode_key(path.as_ref())?;
+ self.local_file_modes.get(&key).copied()
+ }
+
+ /// Captures emulated mode state before rename/copy/link/unlink mutates paths.
+ #[cfg(any(windows, test))]
+ pub(crate) fn capture_local_file_mode(
+ &self,
+ path: impl AsRef,
+ ) -> Option {
+ let path = path.as_ref();
+ let key = local_file_mode_key(path)?;
+ let mode = self.local_file_modes.get(&key).copied()?;
+ Some(LocalFileModeToken {
+ key,
+ mode,
+ last_link: local_file_link_count(path).is_none_or(|count| count <= 1),
+ })
+ }
+
+ /// Copies captured mode state onto a newly materialized destination file.
+ #[cfg(any(windows, test))]
+ pub(crate) fn copy_local_file_mode(
+ &mut self,
+ source: Option<&LocalFileModeToken>,
+ replaced_destination: Option<&LocalFileModeToken>,
+ destination: impl AsRef,
+ ) {
+ if let Some(replaced) = replaced_destination.filter(|token| token.last_link) {
+ self.local_file_modes.remove(&replaced.key);
+ }
+ if let Some(source) = source {
+ self.remember_local_file_mode(destination, source.mode);
+ }
+ }
+
+ /// Moves captured mode state from the old path identity to the renamed file.
+ #[cfg(any(windows, test))]
+ pub(crate) fn rename_local_file_mode(
+ &mut self,
+ source: Option,
+ replaced_destination: Option,
+ destination: impl AsRef,
+ ) {
+ if let Some(replaced) = replaced_destination.filter(|token| token.last_link) {
+ self.local_file_modes.remove(&replaced.key);
+ }
+ if let Some(source) = source {
+ self.local_file_modes.remove(&source.key);
+ self.remember_local_file_mode(destination, source.mode);
+ }
+ }
+
+ /// Purges captured mode state only when unlink removed the inode's last alias.
+ #[cfg(any(windows, test))]
+ pub(crate) fn unlink_local_file_mode(&mut self, removed: Option) {
+ if let Some(removed) = removed.filter(|token| token.last_link) {
+ self.local_file_modes.remove(&removed.key);
+ }
+ }
+
/// Stores the non-owned global scope handle used by eval `global` aliases.
pub fn set_global_scope(&mut self, scope: *mut ElephcEvalScope) -> bool {
if scope.is_null() {
@@ -423,3 +499,238 @@ impl ElephcEvalContext {
format!("{}({}) : eval()'d code", self.call_file, self.call_line)
}
}
+
+/// Builds an inode-like key, falling back to a normalized absolute path.
+#[cfg(any(windows, test))]
+fn local_file_mode_key(path: &std::path::Path) -> Option {
+ let metadata = std::fs::metadata(path).ok()?;
+ if let Some((volume, index)) = local_file_identity(path, &metadata) {
+ return Some(LocalFileModeKey::FileId { volume, index });
+ }
+ Some(LocalFileModeKey::Path(normalize_local_file_mode_path(path)?))
+}
+
+/// Returns a stable host filesystem identity for hard-link-aware mode tracking.
+#[cfg(any(windows, test))]
+fn local_file_identity(
+ _path: &std::path::Path,
+ metadata: &std::fs::Metadata,
+) -> Option<(u64, u64)> {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ return Some((metadata.dev(), metadata.ino()));
+ }
+ #[cfg(windows)]
+ {
+ let _ = metadata;
+ let (volume, index, _) = windows_local_file_info(_path)?;
+ return Some((volume, index));
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = (_path, metadata);
+ None
+ }
+}
+
+/// Returns the host hard-link count when the platform exposes it.
+#[cfg(any(windows, test))]
+fn local_file_link_count(path: &std::path::Path) -> Option {
+ let metadata = std::fs::metadata(path).ok()?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ return Some(metadata.nlink());
+ }
+ #[cfg(windows)]
+ {
+ let _ = metadata;
+ return windows_local_file_info(path).map(|(_, _, links)| links);
+ }
+ #[cfg(not(any(unix, windows)))]
+ {
+ let _ = metadata;
+ None
+ }
+}
+
+/// Canonicalizes an existing path and applies Windows case-insensitive folding.
+#[cfg(any(windows, test))]
+fn normalize_local_file_mode_path(path: &std::path::Path) -> Option {
+ let absolute = std::fs::canonicalize(path).or_else(|_| {
+ if path.is_absolute() {
+ Ok(path.to_path_buf())
+ } else {
+ std::env::current_dir().map(|cwd| cwd.join(path))
+ }
+ });
+ let normalized = absolute.ok()?.to_string_lossy().into_owned();
+ #[cfg(windows)]
+ return Some(normalized.to_lowercase());
+ #[cfg(not(windows))]
+ return Some(normalized);
+}
+
+/// Reads stable Windows volume, file-index, and hard-link-count fields.
+#[cfg(windows)]
+fn windows_local_file_info(path: &std::path::Path) -> Option<(u64, u64, u64)> {
+ use std::ffi::c_void;
+ use std::os::windows::io::AsRawHandle;
+
+ #[repr(C)]
+ struct FileTime {
+ low: u32,
+ high: u32,
+ }
+
+ #[repr(C)]
+ struct ByHandleFileInformation {
+ attributes: u32,
+ creation_time: FileTime,
+ last_access_time: FileTime,
+ last_write_time: FileTime,
+ volume_serial: u32,
+ file_size_high: u32,
+ file_size_low: u32,
+ number_of_links: u32,
+ file_index_high: u32,
+ file_index_low: u32,
+ }
+
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ /// Reads stable filesystem identity for one open Windows handle.
+ fn GetFileInformationByHandle(
+ file: *mut c_void,
+ information: *mut ByHandleFileInformation,
+ ) -> i32;
+ }
+
+ let file = std::fs::File::open(path).ok()?;
+ let mut information = std::mem::MaybeUninit::::uninit();
+ let status = unsafe {
+ GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr())
+ };
+ if status == 0 {
+ return None;
+ }
+ let information = unsafe { information.assume_init() };
+ Some((
+ u64::from(information.volume_serial),
+ (u64::from(information.file_index_high) << 32)
+ | u64::from(information.file_index_low),
+ u64::from(information.number_of_links),
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::PathBuf;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ static NEXT_FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
+
+ /// Creates an isolated filesystem fixture directory for one mode-state test.
+ fn mode_test_dir(label: &str) -> PathBuf {
+ let id = NEXT_FIXTURE_ID.fetch_add(1, Ordering::Relaxed);
+ let path = std::env::temp_dir().join(format!(
+ "elephc_magician_mode_{label}_{}_{id}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&path);
+ std::fs::create_dir(&path).expect("create mode test directory");
+ path
+ }
+
+ /// Verifies canonical and dot-segment aliases resolve to the same mode identity.
+ #[test]
+ fn local_file_mode_resolves_path_aliases() {
+ let dir = mode_test_dir("aliases");
+ let file = dir.join("sample.txt");
+ std::fs::write(&file, b"data").expect("create mode test file");
+ let alias = dir.join(".").join("sample.txt");
+ let canonical = std::fs::canonicalize(&file).expect("canonicalize mode test file");
+ let mut context = ElephcEvalContext::new();
+
+ context.remember_local_file_mode(&alias, 0o640);
+
+ assert_eq!(context.local_file_mode(&file), Some(0o640));
+ assert_eq!(context.local_file_mode(&canonical), Some(0o640));
+ std::fs::remove_dir_all(dir).expect("remove mode test directory");
+ }
+
+ /// Verifies rename moves mode state and copy replaces destination mode state.
+ #[test]
+ fn local_file_mode_follows_rename_and_copy() {
+ let dir = mode_test_dir("rename_copy");
+ let source = dir.join("source.txt");
+ let renamed = dir.join("renamed.txt");
+ let copied = dir.join("copied.txt");
+ std::fs::write(&source, b"source").expect("create source file");
+ std::fs::write(&copied, b"old destination").expect("create copy destination");
+ let mut context = ElephcEvalContext::new();
+ context.remember_local_file_mode(&source, 0o600);
+ context.remember_local_file_mode(&copied, 0o777);
+
+ let source_mode = context.capture_local_file_mode(&source);
+ let renamed_mode = context.capture_local_file_mode(&renamed);
+ std::fs::rename(&source, &renamed).expect("rename source file");
+ context.rename_local_file_mode(source_mode, renamed_mode, &renamed);
+ assert_eq!(context.local_file_mode(&source), None);
+ assert_eq!(context.local_file_mode(&renamed), Some(0o600));
+
+ let source_mode = context.capture_local_file_mode(&renamed);
+ let destination_mode = context.capture_local_file_mode(&copied);
+ std::fs::copy(&renamed, &copied).expect("copy renamed file");
+ context.copy_local_file_mode(source_mode.as_ref(), destination_mode.as_ref(), &copied);
+ assert_eq!(context.local_file_mode(&renamed), Some(0o600));
+ assert_eq!(context.local_file_mode(&copied), Some(0o600));
+ std::fs::remove_dir_all(dir).expect("remove mode test directory");
+ }
+
+ /// Verifies hard-link aliases retain mode state until the last link is removed.
+ #[test]
+ fn local_file_mode_tracks_hard_links_and_recreation() {
+ let dir = mode_test_dir("hard_link");
+ let source = dir.join("source.txt");
+ let alias = dir.join("alias.txt");
+ std::fs::write(&source, b"source").expect("create hard-link source");
+ let mut context = ElephcEvalContext::new();
+ context.remember_local_file_mode(&source, 0o620);
+
+ let source_mode = context.capture_local_file_mode(&source);
+ std::fs::hard_link(&source, &alias).expect("create hard-link alias");
+ context.copy_local_file_mode(source_mode.as_ref(), None, &alias);
+ assert_eq!(context.local_file_mode(&alias), Some(0o620));
+
+ let removed_source = context.capture_local_file_mode(&source);
+ std::fs::remove_file(&source).expect("remove original hard link");
+ context.unlink_local_file_mode(removed_source);
+ assert_eq!(context.local_file_mode(&alias), Some(0o620));
+
+ let removed_alias = context.capture_local_file_mode(&alias);
+ std::fs::remove_file(&alias).expect("remove final hard link");
+ context.unlink_local_file_mode(removed_alias);
+ std::fs::write(&alias, b"replacement").expect("recreate alias path");
+ assert_eq!(context.local_file_mode(&alias), None);
+ std::fs::remove_dir_all(dir).expect("remove mode test directory");
+ }
+
+ /// Verifies Windows case-insensitive path aliases share one emulated mode.
+ #[cfg(windows)]
+ #[test]
+ fn local_file_mode_resolves_windows_case_aliases() {
+ let dir = mode_test_dir("case_alias");
+ let file = dir.join("CaseSample.txt");
+ std::fs::write(&file, b"data").expect("create Windows case test file");
+ let case_alias = dir.join("casesample.TXT");
+ let mut context = ElephcEvalContext::new();
+
+ context.remember_local_file_mode(&file, 0o604);
+
+ assert_eq!(context.local_file_mode(&case_alias), Some(0o604));
+ std::fs::remove_dir_all(dir).expect("remove mode test directory");
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/basename.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/basename.rs
index 5613408937..2b5d346477 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/basename.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/basename.rs
@@ -80,14 +80,14 @@ pub(in crate::interpreter) fn eval_basename_result(
/// Extracts a PHP basename from one path byte string.
pub(in crate::interpreter) fn eval_basename_bytes(path: &[u8], suffix: Option<&[u8]>) -> Vec {
let mut end = path.len();
- while end > 0 && path[end - 1] == b'/' {
+ while end > 0 && eval_basename_separator(path[end - 1]) {
end -= 1;
}
if end == 0 {
return Vec::new();
}
let mut start = end;
- while start > 0 && path[start - 1] != b'/' {
+ while start > 0 && !eval_basename_separator(path[start - 1]) {
start -= 1;
}
let mut result = path[start..end].to_vec();
@@ -98,3 +98,8 @@ pub(in crate::interpreter) fn eval_basename_bytes(path: &[u8], suffix: Option<&[
}
result
}
+
+/// Returns whether one byte separates path components on the current host platform.
+fn eval_basename_separator(byte: u8) -> bool {
+ byte == b'/' || cfg!(windows) && byte == b'\\'
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/chmod.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/chmod.rs
index 0f580a1f8f..7fd4c3e62e 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/chmod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/chmod.rs
@@ -78,6 +78,21 @@ pub(in crate::interpreter) fn eval_chmod_result(
let Some(path) = stream_wrappers::local_filesystem_path(&path) else {
return values.bool_value(false);
};
+ #[cfg(unix)]
let permissions = std::fs::Permissions::from_mode(mode);
- values.bool_value(std::fs::set_permissions(path, permissions).is_ok())
+ #[cfg(windows)]
+ let permissions = {
+ let Ok(metadata) = std::fs::metadata(&path) else {
+ return values.bool_value(false);
+ };
+ let mut permissions = metadata.permissions();
+ permissions.set_readonly(mode & 0o222 == 0);
+ permissions
+ };
+ let changed = std::fs::set_permissions(&path, permissions).is_ok();
+ #[cfg(windows)]
+ if changed {
+ context.remember_local_file_mode(path, mode);
+ }
+ values.bool_value(changed)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/chown.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/chown.rs
index ad33ac4f01..b2e51bcdc2 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/chown.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/chown.rs
@@ -16,6 +16,7 @@ eval_builtin! {
}
use super::super::super::*;
+#[cfg(unix)]
use std::ffi::CString;
use crate::stream_wrappers;
use super::*;
@@ -79,6 +80,17 @@ pub(in crate::interpreter) fn eval_chown_like_result(
let Some(path) = stream_wrappers::local_filesystem_path(&path) else {
return values.bool_value(false);
};
+ eval_local_chown(name, &path, principal, values)
+}
+
+/// Applies Unix owner/group changes to a local filesystem path.
+#[cfg(unix)]
+fn eval_local_chown(
+ name: &str,
+ path: &str,
+ principal: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
let Some(path) = eval_c_string(&path) else {
return values.bool_value(false);
};
@@ -95,6 +107,23 @@ pub(in crate::interpreter) fn eval_chown_like_result(
values.bool_value(status == 0)
}
+/// Reports unsupported Windows owner/group changes without claiming success.
+#[cfg(windows)]
+fn eval_local_chown(
+ name: &str,
+ _path: &str,
+ principal: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ match (name, values.type_tag(principal)?) {
+ ("chown" | "chgrp" | "lchown" | "lchgrp", EVAL_TAG_INT | EVAL_TAG_STRING) => {
+ values.bool_value(false)
+ }
+ ("chown" | "chgrp" | "lchown" | "lchgrp", _) => Err(EvalStatus::RuntimeFatal),
+ _ => Err(EvalStatus::RuntimeFatal),
+ }
+}
+
/// Builds the wrapper metadata option and value for `chown()` or `chgrp()`.
fn eval_chown_metadata_arg(
name: &str,
@@ -118,6 +147,7 @@ fn eval_chown_metadata_arg(
}
/// Resolves one PHP owner/group argument into libc uid/gid slots.
+#[cfg(unix)]
fn eval_chown_principal_ids(
name: &str,
principal: RuntimeCellHandle,
@@ -148,6 +178,7 @@ fn eval_chown_principal_ids(
}
/// Resolves a PHP user-name cell to a libc uid.
+#[cfg(unix)]
fn eval_owner_name_id(
principal: RuntimeCellHandle,
values: &mut impl RuntimeValueOps,
@@ -165,6 +196,7 @@ fn eval_owner_name_id(
}
/// Resolves a PHP group-name cell to a libc gid.
+#[cfg(unix)]
fn eval_group_name_id(
principal: RuntimeCellHandle,
values: &mut impl RuntimeValueOps,
@@ -182,11 +214,13 @@ fn eval_group_name_id(
}
/// Converts a Rust path string into a C string, rejecting embedded NUL bytes.
+#[cfg(unix)]
fn eval_c_string(value: &str) -> Option {
CString::new(value).ok()
}
/// Converts raw PHP bytes into a C string, rejecting embedded NUL bytes.
+#[cfg(unix)]
fn eval_c_bytes(value: &[u8]) -> Option {
CString::new(value).ok()
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/copy.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/copy.rs
index 2e762e9257..7b71770e87 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/copy.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/copy.rs
@@ -78,12 +78,45 @@ pub(in crate::interpreter) fn eval_binary_path_bool_result(
let Some(to) = stream_wrappers::local_filesystem_path(&to) else {
return values.bool_value(false);
};
+ #[cfg(windows)]
+ let source_mode = context.capture_local_file_mode(&from);
+ #[cfg(windows)]
+ let destination_mode = context.capture_local_file_mode(&to);
let ok = match name {
- "copy" => std::fs::copy(from, to).is_ok(),
- "link" => std::fs::hard_link(from, to).is_ok(),
- "rename" => std::fs::rename(from, to).is_ok(),
- "symlink" => std::os::unix::fs::symlink(from, to).is_ok(),
+ "copy" => std::fs::copy(&from, &to).is_ok(),
+ "link" => std::fs::hard_link(&from, &to).is_ok(),
+ "rename" => std::fs::rename(&from, &to).is_ok(),
+ "symlink" => eval_create_symlink(&from, &to),
_ => return Err(EvalStatus::RuntimeFatal),
};
+ #[cfg(windows)]
+ if ok {
+ match name {
+ "copy" | "link" => context.copy_local_file_mode(
+ source_mode.as_ref(),
+ destination_mode.as_ref(),
+ &to,
+ ),
+ "rename" => context.rename_local_file_mode(source_mode, destination_mode, &to),
+ "symlink" => {}
+ _ => unreachable!("filesystem operation was validated above"),
+ }
+ }
values.bool_value(ok)
}
+
+/// Creates a symbolic link with the host-specific path API.
+fn eval_create_symlink(from: &str, to: &str) -> bool {
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink(from, to).is_ok()
+ }
+ #[cfg(windows)]
+ {
+ if std::fs::metadata(from).is_ok_and(|metadata| metadata.is_dir()) {
+ std::os::windows::fs::symlink_dir(from, to).is_ok()
+ } else {
+ std::os::windows::fs::symlink_file(from, to).is_ok()
+ }
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/direct_dispatch.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/direct_dispatch.rs
index ce4dbb5154..fcd91397c7 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/direct_dispatch.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/direct_dispatch.rs
@@ -85,6 +85,10 @@ pub(in crate::interpreter) fn eval_builtin_filesystem_call(
"pclose" => super::pclose::eval_pclose_declared_call(args, context, scope, values),
"pfsockopen" => super::pfsockopen::eval_pfsockopen_declared_call(args, context, scope, values),
"popen" => super::popen::eval_popen_declared_call(args, context, scope, values),
+ "proc_close" => super::proc_close::eval_proc_close_declared_call(args, context, scope, values),
+ "proc_get_status" => super::proc_get_status::eval_proc_get_status_declared_call(args, context, scope, values),
+ "proc_open" => super::proc_open::eval_proc_open_declared_call(args, context, scope, values),
+ "proc_terminate" => super::proc_terminate::eval_proc_terminate_declared_call(args, context, scope, values),
"readdir" => super::readdir::eval_readdir_declared_call(args, context, scope, values),
"readfile" => super::readfile::eval_readfile_declared_call(args, context, scope, values),
"readline" => super::readline::eval_readline_declared_call(args, context, scope, values),
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/dirname.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/dirname.rs
index 0a51247a95..753cf16edf 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/dirname.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/dirname.rs
@@ -90,7 +90,7 @@ pub(in crate::interpreter) fn eval_dirname_once(path: &[u8]) -> Vec {
return b".".to_vec();
}
let mut end = path.len();
- while end > 0 && path[end - 1] == b'/' {
+ while end > 0 && eval_dirname_separator(path[end - 1]) {
end -= 1;
}
if end == 0 {
@@ -99,9 +99,9 @@ pub(in crate::interpreter) fn eval_dirname_once(path: &[u8]) -> Vec {
let mut cursor = end;
while cursor > 0 {
cursor -= 1;
- if path[cursor] == b'/' {
+ if eval_dirname_separator(path[cursor]) {
let mut parent_end = cursor;
- while parent_end > 0 && path[parent_end - 1] == b'/' {
+ while parent_end > 0 && eval_dirname_separator(path[parent_end - 1]) {
parent_end -= 1;
}
return if parent_end == 0 {
@@ -113,3 +113,8 @@ pub(in crate::interpreter) fn eval_dirname_once(path: &[u8]) -> Vec {
}
b".".to_vec()
}
+
+/// Returns whether one byte separates path components on the current host platform.
+fn eval_dirname_separator(byte: u8) -> bool {
+ byte == b'/' || cfg!(windows) && byte == b'\\'
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/disk_free_space.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/disk_free_space.rs
index 206167594f..3bf460be94 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/disk_free_space.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/disk_free_space.rs
@@ -61,8 +61,18 @@ pub(in crate::interpreter) fn eval_disk_space_result(
values: &mut impl RuntimeValueOps,
) -> Result {
let bytes = values.string_bytes(directory)?;
+ #[cfg(unix)]
+ let result = eval_disk_space_bytes_unix(name, &bytes)?;
+ #[cfg(windows)]
+ let result = eval_disk_space_bytes_windows(name, &bytes)?;
+ values.float(result)
+}
+
+/// Queries Unix filesystem capacity through `statvfs`.
+#[cfg(unix)]
+fn eval_disk_space_bytes_unix(name: &str, bytes: &[u8]) -> Result {
let Ok(path) = CString::new(bytes) else {
- return values.float(0.0);
+ return Ok(0.0);
};
let mut stats = std::mem::MaybeUninit::::zeroed();
let status = unsafe {
@@ -70,7 +80,7 @@ pub(in crate::interpreter) fn eval_disk_space_result(
libc::statvfs(path.as_ptr(), stats.as_mut_ptr())
};
if status != 0 {
- return values.float(0.0);
+ return Ok(0.0);
}
let stats = unsafe {
// `statvfs` succeeded, so libc initialized the full stat buffer.
@@ -86,5 +96,48 @@ pub(in crate::interpreter) fn eval_disk_space_result(
"disk_total_space" => stats.f_blocks,
_ => return Err(EvalStatus::RuntimeFatal),
};
- values.float((block_size as f64) * (blocks as f64))
+ Ok((block_size as f64) * (blocks as f64))
+}
+
+/// Queries Windows filesystem capacity through `GetDiskFreeSpaceExW`.
+#[cfg(windows)]
+fn eval_disk_space_bytes_windows(name: &str, bytes: &[u8]) -> Result {
+ use std::os::windows::ffi::OsStrExt;
+
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ /// Reads total and available byte counts for the filesystem containing a Windows path.
+ fn GetDiskFreeSpaceExW(
+ directory: *const u16,
+ free_for_caller: *mut u64,
+ total_bytes: *mut u64,
+ total_free: *mut u64,
+ ) -> i32;
+ }
+
+ let path = String::from_utf8_lossy(bytes);
+ let wide: Vec = std::ffi::OsStr::new(path.as_ref())
+ .encode_wide()
+ .chain(std::iter::once(0))
+ .collect();
+ let mut available = 0_u64;
+ let mut total = 0_u64;
+ let mut free = 0_u64;
+ let status = unsafe {
+ GetDiskFreeSpaceExW(
+ wide.as_ptr(),
+ &mut available,
+ &mut total,
+ &mut free,
+ )
+ };
+ if status == 0 {
+ return Ok(0.0);
+ }
+ let bytes = match name {
+ "disk_free_space" => available,
+ "disk_total_space" => total,
+ _ => return Err(EvalStatus::RuntimeFatal),
+ };
+ Ok(bytes as f64)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/filetype.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/filetype.rs
index 711016f787..11ba0a448c 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/filetype.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/filetype.rs
@@ -88,7 +88,16 @@ pub(in crate::interpreter) fn eval_filetype_result(
"dir"
} else if file_type.is_symlink() {
"link"
- } else if file_type.is_char_device() {
+ } else {
+ eval_special_filetype(&file_type)
+ };
+ values.string(label)
+}
+
+/// Classifies Unix special file kinds that have no Windows filesystem equivalent.
+#[cfg(unix)]
+fn eval_special_filetype(file_type: &std::fs::FileType) -> &'static str {
+ if file_type.is_char_device() {
"char"
} else if file_type.is_block_device() {
"block"
@@ -98,6 +107,11 @@ pub(in crate::interpreter) fn eval_filetype_result(
"socket"
} else {
"unknown"
- };
- values.string(label)
+ }
+}
+
+/// Classifies Windows special files as unknown outside files, directories, and links.
+#[cfg(windows)]
+fn eval_special_filetype(_file_type: &std::fs::FileType) -> &'static str {
+ "unknown"
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/lchgrp.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/lchgrp.rs
index 380b0309f4..64e8288632 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/lchgrp.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/lchgrp.rs
@@ -7,6 +7,7 @@
//! Key details:
//! - Runtime dispatch is declared here and delegated through the ownership/group helper.
+#[cfg(not(windows))]
eval_builtin! {
name: "lchgrp",
area: Filesystem,
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/lchown.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/lchown.rs
index 11df55d24e..bb02320d29 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/lchown.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/lchown.rs
@@ -7,6 +7,7 @@
//! Key details:
//! - Runtime dispatch is declared here and delegated through the ownership/group helper.
+#[cfg(not(windows))]
eval_builtin! {
name: "lchown",
area: Filesystem,
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/linkinfo.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/linkinfo.rs
index 1a5b477830..6e5cb8e4ae 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/linkinfo.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/linkinfo.rs
@@ -63,9 +63,12 @@ pub(in crate::interpreter) fn eval_linkinfo_result(
let Some(path) = stream_wrappers::local_filesystem_path(&path) else {
return values.int(-1);
};
+ #[cfg(unix)]
let dev = match std::fs::symlink_metadata(path) {
Ok(metadata) => i64::try_from(metadata.dev()).map_err(|_| EvalStatus::RuntimeFatal)?,
Err(_) => -1,
};
+ #[cfg(windows)]
+ let dev = if std::fs::symlink_metadata(path).is_ok() { 0 } else { -1 };
values.int(dev)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/mod.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/mod.rs
index 774fad9725..378c9748ad 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/mod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/mod.rs
@@ -77,6 +77,10 @@ mod pathinfo;
mod pclose;
mod pfsockopen;
mod popen;
+mod proc_close;
+mod proc_get_status;
+mod proc_open;
+mod proc_terminate;
mod readdir;
mod readfile;
mod readline;
@@ -131,7 +135,7 @@ mod stream_wrapper_restore;
mod stream_wrapper_unregister;
mod streams;
mod symlink;
-mod sys_get_temp_dir;
+pub(in crate::interpreter) mod sys_get_temp_dir;
mod tempnam;
mod tmpfile;
mod touch;
@@ -168,6 +172,7 @@ pub(in crate::interpreter) use flock::{eval_builtin_flock, eval_flock_result};
pub(in crate::interpreter) use fsockopen::{
eval_builtin_fsockopen_call, eval_fsockopen_with_error_result,
};
+pub(in crate::interpreter) use proc_open::eval_builtin_proc_open_call;
pub(in crate::interpreter) use stream_select::{
eval_builtin_stream_select_call, eval_stream_select_result,
};
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/path.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/path.rs
index b313415c73..9dd83e7beb 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/path.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/path.rs
@@ -25,9 +25,22 @@ pub(in crate::interpreter) fn eval_path_is_readable(path: &std::path::Path) -> b
/// Returns whether a path has any executable bit set in its Unix mode.
pub(in crate::interpreter) fn eval_path_is_executable(path: &std::path::Path) -> bool {
+ #[cfg(unix)]
+ {
std::fs::metadata(path)
.map(|metadata| metadata.mode() & 0o111 != 0)
.unwrap_or(false)
+ }
+ #[cfg(windows)]
+ {
+ path.is_file()
+ && path
+ .extension()
+ .and_then(std::ffi::OsStr::to_str)
+ .is_some_and(|extension| {
+ matches!(extension.to_ascii_lowercase().as_str(), "exe" | "com" | "bat" | "cmd")
+ })
+ }
}
/// Returns whether a path can be written by the current process.
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_close.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_close.rs
new file mode 100644
index 0000000000..2a43a652cd
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_close.rs
@@ -0,0 +1,62 @@
+//! Purpose:
+//! Declarative eval registry entry and process-resource implementation for `proc_close`.
+//!
+//! Called from:
+//! - Eval builtin registry filesystem dispatch.
+//!
+//! Key details:
+//! - Only process resources created by eval `proc_open` are accepted and waited.
+
+eval_builtin! {
+ name: "proc_close",
+ area: Filesystem,
+ params: [process],
+ direct: Filesystem,
+ values: Filesystem,
+}
+
+use super::super::super::*;
+
+/// Evaluates a direct `proc_close(process)` call.
+pub(in crate::interpreter) fn eval_proc_close_declared_call(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [process] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ let process = eval_expr(process, context, scope, values)?;
+ eval_proc_close_result(process, context, values)
+}
+
+/// Evaluates `proc_close` from normalized argument values.
+pub(in crate::interpreter) fn eval_proc_close_declared_values_result(
+ args: &[RuntimeCellHandle],
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [process] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ eval_proc_close_result(*process, context, values)
+}
+
+/// Waits for an eval process resource and returns its child exit status.
+fn eval_proc_close_result(
+ process: RuntimeCellHandle,
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ if values.type_tag(process)? != EVAL_TAG_RESOURCE {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let id = eval_int_value(process, values)?
+ .checked_sub(1)
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ match context.stream_resources_mut().close_process(id) {
+ Some(status) => values.int(status),
+ None => values.bool_value(false),
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_get_status.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_get_status.rs
new file mode 100644
index 0000000000..1b932b9910
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_get_status.rs
@@ -0,0 +1,78 @@
+//! Purpose:
+//! Declares and implements eval-time `proc_get_status` for `proc_open` resources.
+//!
+//! Called from:
+//! - The filesystem builtin direct and normalized-value dispatchers.
+//!
+//! Key details:
+//! - Status inspection is non-consuming so a later `proc_close` can still wait.
+
+eval_builtin! {
+ name: "proc_get_status",
+ area: Filesystem,
+ params: [process],
+ direct: Filesystem,
+ values: Filesystem,
+}
+
+use super::super::super::*;
+
+/// Evaluates a direct `proc_get_status(process)` call.
+pub(in crate::interpreter) fn eval_proc_get_status_declared_call(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [process] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ let process = eval_expr(process, context, scope, values)?;
+ eval_proc_get_status_result(process, context, values)
+}
+
+/// Evaluates `proc_get_status` from normalized argument values.
+pub(in crate::interpreter) fn eval_proc_get_status_declared_values_result(
+ args: &[RuntimeCellHandle],
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [process] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ eval_proc_get_status_result(*process, context, values)
+}
+
+/// Builds PHP's process-status array for one live eval process resource.
+fn eval_proc_get_status_result(
+ process: RuntimeCellHandle,
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ if values.type_tag(process)? != EVAL_TAG_RESOURCE {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let id = eval_int_value(process, values)?
+ .checked_sub(1)
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ let Some(status) = context.stream_resources_mut().process_status(id) else {
+ return values.bool_value(false);
+ };
+ let entries = [
+ ("command", values.string(&status.command)?),
+ ("pid", values.int(status.pid)?),
+ ("cached", values.bool_value(status.cached)?),
+ ("running", values.bool_value(status.running)?),
+ ("signaled", values.bool_value(status.signaled)?),
+ ("stopped", values.bool_value(status.stopped)?),
+ ("exitcode", values.int(status.exitcode)?),
+ ("termsig", values.int(status.termsig)?),
+ ("stopsig", values.int(status.stopsig)?),
+ ];
+ let mut result = values.array_new(entries.len())?;
+ for (name, value) in entries {
+ let name = values.string(name)?;
+ result = values.array_set(result, name, value)?;
+ }
+ Ok(result)
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_open.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_open.rs
new file mode 100644
index 0000000000..caa961cb3e
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_open.rs
@@ -0,0 +1,220 @@
+//! Purpose:
+//! Declarative eval registry entry and process-resource implementation for `proc_open`.
+//!
+//! Called from:
+//! - Eval builtin registry dispatch and the source-sensitive call dispatcher.
+//!
+//! Key details:
+//! - The pipes argument is written by reference and process ownership remains in
+//! `EvalStreamResources` until `proc_close` waits for it.
+
+use super::super::spec::EvalBuiltinDefaultValue;
+
+eval_builtin! {
+ name: "proc_open",
+ area: Filesystem,
+ params: [
+ command,
+ descriptor_spec,
+ pipes: by_ref,
+ cwd = EvalBuiltinDefaultValue::Null,
+ env_vars = EvalBuiltinDefaultValue::Null,
+ options = EvalBuiltinDefaultValue::Null
+ ],
+ by_ref: [pipes],
+ direct: none,
+ values: Filesystem,
+}
+
+use super::super::super::*;
+use super::*;
+use crate::stream_resources::EvalProcDescriptor;
+
+/// Evaluates positional `proc_open` calls that cannot preserve a writable pipes target.
+pub(in crate::interpreter) fn eval_proc_open_declared_call(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ if !(3..=6).contains(&args.len()) {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let mut evaluated = Vec::with_capacity(args.len());
+ for arg in args {
+ evaluated.push(eval_expr(arg, context, scope, values)?);
+ }
+ values.warning("proc_open(): Argument #3 ($pipes) must be passed by reference, value given")?;
+ eval_proc_open_values(&evaluated, None, context, values)
+}
+
+/// Evaluates materialized `proc_open` arguments without a caller lvalue.
+pub(in crate::interpreter) fn eval_proc_open_declared_values_result(
+ args: &[RuntimeCellHandle],
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ eval_proc_open_values(args, None, context, values)
+}
+
+/// Evaluates source call metadata while retaining the by-reference pipes lvalue.
+pub(in crate::interpreter) fn eval_builtin_proc_open_call(
+ args: &[EvalCallArg],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let evaluated = eval_call_arg_values(args, context, scope, values)?;
+ let (bound, _) = bind_evaluated_ref_builtin_args(
+ &["command", "descriptor_spec", "pipes", "cwd", "env_vars", "options"],
+ &evaluated,
+ false,
+ )?;
+ let command = required_evaluated_ref_arg(&bound, 0)?;
+ let descriptor = required_evaluated_ref_arg(&bound, 1)?;
+ let pipes = required_evaluated_ref_arg(&bound, 2)?;
+ let target = pipes.ref_target.clone().ok_or(EvalStatus::RuntimeFatal)?;
+ let mut selected = vec![command.value, descriptor.value, pipes.value];
+ for index in 3..=5 {
+ if let Some(arg) = optional_evaluated_ref_arg(&bound, index) {
+ selected.push(arg.value);
+ }
+ }
+ eval_proc_open_values(&selected, Some(&target), context, values)
+}
+
+/// Starts one shell process and returns its eval-local process resource.
+fn eval_proc_open_values(
+ args: &[RuntimeCellHandle],
+ pipes_target: Option<&EvalReferenceTarget>,
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ if !(3..=6).contains(&args.len()) || !values.is_array_like(args[1])? {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let command = eval_path_string(args[0], values)?;
+ let descriptors = eval_proc_descriptors(args[1], values)?;
+ let cwd = match args.get(3).copied() {
+ Some(value) if values.type_tag(value)? != EVAL_TAG_NULL => {
+ Some(eval_path_string(value, values)?)
+ }
+ _ => None,
+ };
+ let env = match args.get(4).copied() {
+ Some(value) if values.type_tag(value)? != EVAL_TAG_NULL => {
+ Some(eval_proc_environment(value, values)?)
+ }
+ _ => None,
+ };
+ let bypass_shell = match args.get(5).copied() {
+ Some(value) if values.type_tag(value)? != EVAL_TAG_NULL => {
+ let key = values.string("bypass_shell")?;
+ let option = values.array_get(value, key)?;
+ values.type_tag(option)? != EVAL_TAG_NULL && values.truthy(option)?
+ }
+ _ => false,
+ };
+ match context.stream_resources_mut().open_process(
+ &command,
+ &descriptors,
+ cwd.as_deref(),
+ env.as_deref(),
+ bypass_shell,
+ ) {
+ Some(result) => {
+ let mut pipes = values.array_new(result.pipes.len())?;
+ for (descriptor, pipe_id) in result.pipes {
+ let key = values.int(descriptor)?;
+ let pipe = values.resource(pipe_id)?;
+ pipes = values.array_set(pipes, key, pipe)?;
+ }
+ if let Some(target) = pipes_target {
+ eval_write_direct_ref_target(
+ target,
+ pipes,
+ context,
+ values,
+ Some(ScopeCellOwnership::Owned),
+ )?;
+ }
+ values.resource(result.process_id)
+ }
+ None => values.bool_value(false),
+ }
+}
+
+/// Parses PHP's descriptor specification into the three child stdio slots.
+fn eval_proc_descriptors(
+ spec: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result<[Option; 3], EvalStatus> {
+ let mut descriptors = std::array::from_fn(|_| None);
+ for position in 0..values.array_len(spec)? {
+ let key = values.array_iter_key(spec, position)?;
+ let descriptor = usize::try_from(eval_int_value(key, values)?)
+ .ok()
+ .filter(|descriptor| *descriptor < descriptors.len())
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ let entry = values.array_get(spec, key)?;
+ if values.type_tag(entry)? == EVAL_TAG_INT {
+ let target = usize::try_from(eval_int_value(entry, values)?)
+ .ok()
+ .filter(|target| *target < descriptors.len())
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ descriptors[descriptor] = Some(EvalProcDescriptor::Redirect(target));
+ continue;
+ }
+ if !values.is_array_like(entry)? {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let kind = eval_proc_descriptor_string(entry, 0, values)?;
+ descriptors[descriptor] = Some(match kind.as_str() {
+ "pipe" => {
+ let mode = eval_proc_descriptor_string(entry, 1, values)?;
+ match mode.as_str() {
+ "r" => EvalProcDescriptor::Pipe { child_reads: true },
+ "w" => EvalProcDescriptor::Pipe { child_reads: false },
+ _ => return Err(EvalStatus::RuntimeFatal),
+ }
+ }
+ "file" => EvalProcDescriptor::File {
+ path: eval_proc_descriptor_string(entry, 1, values)?,
+ mode: eval_proc_descriptor_string(entry, 2, values)?,
+ },
+ _ => return Err(EvalStatus::RuntimeFatal),
+ });
+ }
+ Ok(descriptors)
+}
+
+/// Reads one string field from an indexed descriptor tuple.
+fn eval_proc_descriptor_string(
+ descriptor: RuntimeCellHandle,
+ index: i64,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let key = values.int(index)?;
+ let value = values.array_get(descriptor, key)?;
+ if values.type_tag(value)? != EVAL_TAG_STRING {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ eval_path_string(value, values)
+}
+
+/// Converts a PHP environment array into the exact child environment mapping.
+fn eval_proc_environment(
+ env: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result, EvalStatus> {
+ if !values.is_array_like(env)? {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let mut result = Vec::with_capacity(values.array_len(env)?);
+ for position in 0..values.array_len(env)? {
+ let key = values.array_iter_key(env, position)?;
+ let value = values.array_get(env, key)?;
+ result.push((eval_path_string(key, values)?, eval_path_string(value, values)?));
+ }
+ Ok(result)
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_terminate.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_terminate.rs
new file mode 100644
index 0000000000..c8b5a943d9
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/proc_terminate.rs
@@ -0,0 +1,73 @@
+//! Purpose:
+//! Declares and implements eval-time process termination for `proc_open` resources.
+//!
+//! Called from:
+//! - The filesystem builtin direct and normalized-value dispatchers.
+//!
+//! Key details:
+//! - Unix forwards the requested signal while Windows uses the child termination API.
+
+use super::super::spec::EvalBuiltinDefaultValue;
+
+eval_builtin! {
+ name: "proc_terminate",
+ area: Filesystem,
+ params: [process, signal = EvalBuiltinDefaultValue::Int(15)],
+ direct: Filesystem,
+ values: Filesystem,
+}
+
+use super::super::super::*;
+
+/// Evaluates a direct `proc_terminate(process, signal = 15)` call.
+pub(in crate::interpreter) fn eval_proc_terminate_declared_call(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let (process, signal) = match args {
+ [process] => (eval_expr(process, context, scope, values)?, 15),
+ [process, signal] => (
+ eval_expr(process, context, scope, values)?,
+ eval_int_value(eval_expr(signal, context, scope, values)?, values)?,
+ ),
+ _ => return Err(EvalStatus::RuntimeFatal),
+ };
+ eval_proc_terminate_result(process, signal, context, values)
+}
+
+/// Evaluates `proc_terminate` from normalized argument values.
+pub(in crate::interpreter) fn eval_proc_terminate_declared_values_result(
+ args: &[RuntimeCellHandle],
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let (process, signal) = match args {
+ [process] => (*process, 15),
+ [process, signal] => (*process, eval_int_value(*signal, values)?),
+ _ => return Err(EvalStatus::RuntimeFatal),
+ };
+ eval_proc_terminate_result(process, signal, context, values)
+}
+
+/// Terminates one eval process resource and returns PHP's boolean success result.
+fn eval_proc_terminate_result(
+ process: RuntimeCellHandle,
+ signal: i64,
+ context: &mut ElephcEvalContext,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ if values.type_tag(process)? != EVAL_TAG_RESOURCE {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ let id = eval_int_value(process, values)?
+ .checked_sub(1)
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ values.bool_value(
+ context
+ .stream_resources_mut()
+ .terminate_process(id, signal)
+ .unwrap_or(false),
+ )
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/stat.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/stat.rs
index 675053d8ba..37479733c2 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/stat.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/stat.rs
@@ -18,6 +18,8 @@ eval_builtin! {
use super::super::super::*;
use crate::stream_wrappers;
use super::*;
+#[cfg(windows)]
+use std::os::windows::fs::MetadataExt as WindowsMetadataExt;
/// Dispatches direct eval calls for the `stat` filesystem builtin through the area dispatcher.
pub(in crate::interpreter) fn eval_stat_declared_call(
@@ -73,11 +75,12 @@ pub(in crate::interpreter) fn eval_file_stat_scalar_result(
_ => values.bool_value(false),
};
};
- let metadata = match std::fs::metadata(path) {
+ let metadata = match std::fs::metadata(&path) {
Ok(metadata) => metadata,
Err(_) if name == "filemtime" => return values.int(0),
Err(_) => return values.bool_value(false),
};
+ #[cfg(unix)]
match name {
"fileatime" => values.int(metadata.atime()),
"filectime" => values.int(metadata.ctime()),
@@ -90,6 +93,29 @@ pub(in crate::interpreter) fn eval_file_stat_scalar_result(
"fileperms" => values.int(i64::from(metadata.mode())),
_ => Err(EvalStatus::RuntimeFatal),
}
+ #[cfg(windows)]
+ let windows_info = eval_windows_file_info(&path);
+ #[cfg(windows)]
+ match name {
+ "fileatime" => values.int(eval_windows_filetime_seconds(metadata.last_access_time())),
+ "filectime" => values.int(eval_windows_filetime_seconds(metadata.creation_time())),
+ "filegroup" | "fileowner" => values.int(0),
+ "fileinode" => values.int(
+ windows_info
+ .and_then(|info| i64::try_from(info.file_index).ok())
+ .unwrap_or(0),
+ ),
+ "filemtime" => values.int(eval_windows_filetime_seconds(metadata.last_write_time())),
+ "fileperms" => {
+ let physical_mode = eval_windows_metadata_mode(&metadata);
+ let mode = context
+ .local_file_mode(&path)
+ .map(|permissions| (physical_mode & !0o7777) | i64::from(permissions))
+ .unwrap_or(physical_mode);
+ values.int(mode)
+ }
+ _ => Err(EvalStatus::RuntimeFatal),
+ }
}
/// Evaluates PHP `stat($filename)` or `lstat($filename)` over one eval expression.
pub(in crate::interpreter) fn eval_builtin_stat_array(
@@ -121,15 +147,23 @@ pub(in crate::interpreter) fn eval_stat_array_result(
return values.bool_value(false);
};
let metadata = match name {
- "stat" => std::fs::metadata(path),
- "lstat" => std::fs::symlink_metadata(path),
+ "stat" => std::fs::metadata(&path),
+ "lstat" => std::fs::symlink_metadata(&path),
_ => return Err(EvalStatus::RuntimeFatal),
};
let metadata = match metadata {
Ok(metadata) => metadata,
Err(_) => return values.bool_value(false),
};
- eval_stat_metadata_array(&metadata, values)
+ #[cfg(unix)]
+ return eval_stat_metadata_array(&metadata, values);
+ #[cfg(windows)]
+ eval_stat_metadata_array_with_windows_info(
+ &metadata,
+ eval_windows_file_info(&path),
+ context.local_file_mode(&path),
+ values,
+ )
}
/// Converts filesystem metadata into PHP's numeric-and-string keyed stat array.
@@ -137,6 +171,7 @@ pub(in crate::interpreter) fn eval_stat_metadata_array(
metadata: &std::fs::Metadata,
values: &mut impl RuntimeValueOps,
) -> Result {
+ #[cfg(unix)]
let fields = [
("dev", eval_u64_to_i64(metadata.dev())?),
("ino", eval_u64_to_i64(metadata.ino())?),
@@ -152,12 +187,155 @@ pub(in crate::interpreter) fn eval_stat_metadata_array(
("blksize", eval_u64_to_i64(metadata.blksize())?),
("blocks", eval_u64_to_i64(metadata.blocks())?),
];
+ #[cfg(windows)]
+ return eval_stat_metadata_array_with_windows_info(metadata, None, None, values);
+ #[cfg(unix)]
+ {
let mut result = values.assoc_new(fields.len() * 2)?;
for (index, (name, value)) in fields.iter().enumerate() {
result = eval_stat_array_set_int_key(result, index, *value, values)?;
result = eval_stat_array_set_string_key(result, name, *value, values)?;
}
Ok(result)
+ }
+}
+
+/// Converts Windows metadata and optional handle identity into PHP's stat array.
+#[cfg(windows)]
+fn eval_stat_metadata_array_with_windows_info(
+ metadata: &std::fs::Metadata,
+ info: Option,
+ mode_override: Option,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let (device, inode, links) = info
+ .map(|info| {
+ (
+ i64::from(info.volume_serial),
+ i64::try_from(info.file_index).unwrap_or(0),
+ i64::from(info.number_of_links),
+ )
+ })
+ .unwrap_or((0, 0, 1));
+ let physical_mode = eval_windows_metadata_mode(metadata);
+ let mode = mode_override
+ .map(|permissions| (physical_mode & !0o7777) | i64::from(permissions))
+ .unwrap_or(physical_mode);
+ let fields = [
+ ("dev", device),
+ ("ino", inode),
+ ("mode", mode),
+ ("nlink", links),
+ ("uid", 0),
+ ("gid", 0),
+ ("rdev", 0),
+ ("size", eval_u64_to_i64(metadata.file_size())?),
+ ("atime", eval_windows_filetime_seconds(metadata.last_access_time())),
+ ("mtime", eval_windows_filetime_seconds(metadata.last_write_time())),
+ ("ctime", eval_windows_filetime_seconds(metadata.creation_time())),
+ ("blksize", 0),
+ ("blocks", 0),
+ ];
+ let mut result = values.assoc_new(fields.len() * 2)?;
+ for (index, (name, value)) in fields.iter().enumerate() {
+ result = eval_stat_array_set_int_key(result, index, *value, values)?;
+ result = eval_stat_array_set_string_key(result, name, *value, values)?;
+ }
+ Ok(result)
+}
+
+/// Stable file identity fields exposed by `GetFileInformationByHandle`.
+#[cfg(windows)]
+#[derive(Clone, Copy)]
+struct WindowsFileInfo {
+ volume_serial: u32,
+ number_of_links: u32,
+ file_index: u64,
+}
+
+/// Reads stable Windows volume, link-count, and file-index metadata for a path.
+#[cfg(windows)]
+fn eval_windows_file_info(path: &str) -> Option {
+ use std::ffi::c_void;
+ use std::os::windows::io::AsRawHandle;
+
+ #[repr(C)]
+ struct FileTime {
+ low: u32,
+ high: u32,
+ }
+
+ #[repr(C)]
+ struct ByHandleFileInformation {
+ attributes: u32,
+ creation_time: FileTime,
+ last_access_time: FileTime,
+ last_write_time: FileTime,
+ volume_serial: u32,
+ file_size_high: u32,
+ file_size_low: u32,
+ number_of_links: u32,
+ file_index_high: u32,
+ file_index_low: u32,
+ }
+
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ /// Reads filesystem identity and timestamps for one open Windows file handle.
+ fn GetFileInformationByHandle(
+ file: *mut c_void,
+ information: *mut ByHandleFileInformation,
+ ) -> i32;
+ }
+
+ let file = std::fs::File::open(path).ok()?;
+ let mut information = std::mem::MaybeUninit::::uninit();
+ let status = unsafe {
+ GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr())
+ };
+ if status == 0 {
+ return None;
+ }
+ let information = unsafe { information.assume_init() };
+ Some(WindowsFileInfo {
+ volume_serial: information.volume_serial,
+ number_of_links: information.number_of_links,
+ file_index: (u64::from(information.file_index_high) << 32)
+ | u64::from(information.file_index_low),
+ })
+}
+
+/// Synthesizes PHP's POSIX-shaped mode bits from Windows metadata.
+#[cfg(windows)]
+fn eval_windows_metadata_mode(metadata: &std::fs::Metadata) -> i64 {
+ const FILE_ATTRIBUTE_READONLY: u32 = 0x0000_0001;
+ const S_IFDIR: i64 = 0o040000;
+ const S_IFLNK: i64 = 0o120000;
+ const S_IFREG: i64 = 0o100000;
+ let kind = if metadata.file_type().is_symlink() {
+ S_IFLNK
+ } else if metadata.is_dir() {
+ S_IFDIR
+ } else {
+ S_IFREG
+ };
+ let permissions = if metadata.file_attributes() & FILE_ATTRIBUTE_READONLY != 0 {
+ 0o444
+ } else {
+ 0o666
+ };
+ kind | permissions
+}
+
+/// Converts a Windows FILETIME count to whole Unix epoch seconds.
+#[cfg(windows)]
+fn eval_windows_filetime_seconds(filetime: u64) -> i64 {
+ const WINDOWS_TO_UNIX_EPOCH_100NS: u64 = 116_444_736_000_000_000;
+ filetime
+ .saturating_sub(WINDOWS_TO_UNIX_EPOCH_100NS)
+ .checked_div(10_000_000)
+ .and_then(|seconds| i64::try_from(seconds).ok())
+ .unwrap_or(0)
}
/// Inserts one integer stat field under a numeric PHP array key.
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/sys_get_temp_dir.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/sys_get_temp_dir.rs
index 789b1d91c2..3a348f0c2b 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/sys_get_temp_dir.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/sys_get_temp_dir.rs
@@ -50,9 +50,48 @@ pub(in crate::interpreter) fn eval_builtin_sys_get_temp_dir(
eval_sys_get_temp_dir_result(values)
}
-/// Returns the same temporary directory literal as the native static builtin.
+/// Returns the process temporary directory, matching the compiled builtin.
+///
+/// On Windows the compiled path calls `__rt_sys_get_temp_dir` (GetTempPathW-backed),
+/// so returning the literal `/tmp` here made an eval fragment contradict the rest of
+/// its own binary with a path that is not even valid on that platform.
+///
+/// The POSIX arm resolves `TMPDIR` exactly as the compiled runtime helper
+/// `__rt_php_temp_dir` does, so an eval fragment and the binary around it always
+/// name the same directory.
pub(in crate::interpreter) fn eval_sys_get_temp_dir_result(
values: &mut impl RuntimeValueOps,
) -> Result {
- values.string("/tmp")
+ values.string(&eval_temp_dir())
+}
+
+/// Resolves the temporary directory for the platform this interpreter targets.
+pub(in crate::interpreter) fn eval_temp_dir() -> String {
+ if cfg!(target_os = "windows") {
+ // php strips the trailing separator GetTempPath() always appends; the TMP /
+ // TEMP / USERPROFILE order mirrors what the Win32 call itself consults.
+ for key in ["TMP", "TEMP", "USERPROFILE"] {
+ if let Ok(dir) = std::env::var(key) {
+ let trimmed = dir.trim_end_matches(['\\', '/']);
+ if !trimmed.is_empty() {
+ return trimmed.to_string();
+ }
+ }
+ }
+ return "C:\\Windows\\Temp".to_string();
+ }
+
+ // php reads TMPDIR first and drops **exactly one** trailing slash, so
+ // "/var/tmp/probe///" resolves to "/var/tmp/probe//" and a bare "/" resolves to
+ // the empty string. Only an unset or empty TMPDIR falls back to P_tmpdir, which
+ // php returns verbatim -- trailing slash included on macOS.
+ if let Ok(dir) = std::env::var("TMPDIR") {
+ if !dir.is_empty() {
+ return dir.strip_suffix('/').unwrap_or(&dir).to_string();
+ }
+ }
+ if cfg!(target_os = "macos") {
+ return "/var/tmp/".to_string();
+ }
+ "/tmp".to_string()
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/umask.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/umask.rs
index 3b2f1c97c0..d68a39c64b 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/umask.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/umask.rs
@@ -67,13 +67,13 @@ pub(in crate::interpreter) fn eval_umask_result(
let previous = match mask {
Some(mask) => {
let mask = eval_int_value(mask, values)? as u32;
- unsafe { umask(mask) }
+ eval_os_umask(mask)
}
- None => unsafe {
- let current = umask(0);
- umask(current);
+ None => {
+ let current = eval_os_umask(0);
+ eval_os_umask(current);
current
- },
+ }
};
values.int(i64::from(previous))
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/unlink.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/unlink.rs
index c0856464de..2377d54e8e 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/unlink.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/unlink.rs
@@ -71,5 +71,12 @@ pub(in crate::interpreter) fn eval_unlink_result(
let Some(path) = stream_wrappers::local_filesystem_path(&path) else {
return values.bool_value(false);
};
- values.bool_value(std::fs::remove_file(path).is_ok())
+ #[cfg(windows)]
+ let removed_mode = context.capture_local_file_mode(&path);
+ let removed = std::fs::remove_file(&path).is_ok();
+ #[cfg(windows)]
+ if removed {
+ context.unlink_local_file_mode(removed_mode);
+ }
+ values.bool_value(removed)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/user_wrapper_stat.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/user_wrapper_stat.rs
index a96813d125..0e91fe555f 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/user_wrapper_stat.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/user_wrapper_stat.rs
@@ -13,6 +13,15 @@
use super::super::super::*;
+const EVAL_S_IFMT: i64 = 0o170000;
+const EVAL_S_IFIFO: i64 = 0o010000;
+const EVAL_S_IFCHR: i64 = 0o020000;
+const EVAL_S_IFDIR: i64 = 0o040000;
+const EVAL_S_IFBLK: i64 = 0o060000;
+const EVAL_S_IFREG: i64 = 0o100000;
+const EVAL_S_IFLNK: i64 = 0o120000;
+const EVAL_S_IFSOCK: i64 = 0o140000;
+
/// Dispatches `fstat()` to a wrapper object's `stream_stat()`.
pub(in crate::interpreter) fn eval_user_wrapper_fstat_result(
id: i64,
@@ -51,10 +60,10 @@ pub(in crate::interpreter) fn eval_user_wrapper_file_probe_from_stat(
let mode = eval_user_wrapper_stat_int_field(stat, "mode", values)?.unwrap_or(0);
let result = match name {
"file_exists" => true,
- "is_dir" => eval_mode_kind(mode) == libc::S_IFDIR as i64,
+ "is_dir" => eval_mode_kind(mode) == EVAL_S_IFDIR,
"is_executable" => mode & 0o111 != 0,
- "is_file" => eval_mode_kind(mode) == libc::S_IFREG as i64,
- "is_link" => eval_mode_kind(mode) == libc::S_IFLNK as i64,
+ "is_file" => eval_mode_kind(mode) == EVAL_S_IFREG,
+ "is_link" => eval_mode_kind(mode) == EVAL_S_IFLNK,
"is_readable" => mode & 0o444 != 0,
"is_writable" | "is_writeable" => mode & 0o222 != 0,
_ => return Err(EvalStatus::RuntimeFatal),
@@ -102,18 +111,18 @@ pub(in crate::interpreter) fn eval_user_wrapper_stat_int_field(
/// Maps one POSIX mode value to PHP's `filetype()` label.
pub(in crate::interpreter) fn eval_filetype_label_from_mode(mode: i64) -> &'static str {
match eval_mode_kind(mode) {
- kind if kind == libc::S_IFREG as i64 => "file",
- kind if kind == libc::S_IFDIR as i64 => "dir",
- kind if kind == libc::S_IFLNK as i64 => "link",
- kind if kind == libc::S_IFCHR as i64 => "char",
- kind if kind == libc::S_IFBLK as i64 => "block",
- kind if kind == libc::S_IFIFO as i64 => "fifo",
- kind if kind == libc::S_IFSOCK as i64 => "socket",
+ kind if kind == EVAL_S_IFREG => "file",
+ kind if kind == EVAL_S_IFDIR => "dir",
+ kind if kind == EVAL_S_IFLNK => "link",
+ kind if kind == EVAL_S_IFCHR => "char",
+ kind if kind == EVAL_S_IFBLK => "block",
+ kind if kind == EVAL_S_IFIFO => "fifo",
+ kind if kind == EVAL_S_IFSOCK => "socket",
_ => "unknown",
}
}
/// Masks one POSIX mode value down to its file-kind bits.
fn eval_mode_kind(mode: i64) -> i64 {
- mode & (libc::S_IFMT as i64)
+ mode & EVAL_S_IFMT
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/filesystem/values_dispatch.rs b/crates/elephc-magician/src/interpreter/builtins/filesystem/values_dispatch.rs
index 6eef8321e4..ae44de3caa 100644
--- a/crates/elephc-magician/src/interpreter/builtins/filesystem/values_dispatch.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/filesystem/values_dispatch.rs
@@ -85,6 +85,10 @@ pub(in crate::interpreter) fn eval_filesystem_values_result(
"pclose" => super::pclose::eval_pclose_declared_values_result(evaluated_args, context, values),
"pfsockopen" => super::pfsockopen::eval_pfsockopen_declared_values_result(evaluated_args, context, values),
"popen" => super::popen::eval_popen_declared_values_result(evaluated_args, context, values),
+ "proc_close" => super::proc_close::eval_proc_close_declared_values_result(evaluated_args, context, values),
+ "proc_get_status" => super::proc_get_status::eval_proc_get_status_declared_values_result(evaluated_args, context, values),
+ "proc_open" => super::proc_open::eval_proc_open_declared_values_result(evaluated_args, context, values),
+ "proc_terminate" => super::proc_terminate::eval_proc_terminate_declared_values_result(evaluated_args, context, values),
"readdir" => super::readdir::eval_readdir_declared_values_result(evaluated_args, context, values),
"readfile" => super::readfile::eval_readfile_declared_values_result(evaluated_args, context, values),
"readline" => super::readline::eval_readline_declared_values_result(evaluated_args, context, values),
diff --git a/crates/elephc-magician/src/interpreter/builtins/hooks/direct.rs b/crates/elephc-magician/src/interpreter/builtins/hooks/direct.rs
index 259b270ca1..e61e840b91 100644
--- a/crates/elephc-magician/src/interpreter/builtins/hooks/direct.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/hooks/direct.rs
@@ -198,6 +198,8 @@ pub(in crate::interpreter) enum EvalDirectHook {
Rad2deg,
/// Dispatches `rand(...)`.
Rand,
+ /// Dispatches `random_bytes(...)`.
+ RandomBytes,
/// Dispatches `random_int(...)`.
RandomInt,
/// Dispatches `round(...)`.
@@ -446,6 +448,7 @@ impl EvalDirectHook {
Self::Pow => eval_builtin_pow(args, context, scope, values),
Self::Rad2deg => eval_builtin_rad2deg(args, context, scope, values),
Self::Rand => eval_builtin_rand(args, context, scope, values),
+ Self::RandomBytes => eval_builtin_random_bytes(args, context, scope, values),
Self::RandomInt => eval_builtin_random_int(args, context, scope, values),
Self::Round => eval_builtin_round(args, context, scope, values),
Self::MbEregMatch => eval_builtin_mb_ereg_match(args, context, scope, values),
@@ -478,6 +481,8 @@ impl EvalDirectHook {
Self::Sinh => eval_builtin_sinh(args, context, scope, values),
Self::Slashes => match name {
"addslashes" => eval_builtin_addslashes(args, context, scope, values),
+ "escapeshellarg" => eval_builtin_escapeshellarg(args, context, scope, values),
+ "escapeshellcmd" => eval_builtin_escapeshellcmd(args, context, scope, values),
"stripslashes" => eval_builtin_stripslashes(args, context, scope, values),
_ => Err(EvalStatus::RuntimeFatal),
},
diff --git a/crates/elephc-magician/src/interpreter/builtins/hooks/values.rs b/crates/elephc-magician/src/interpreter/builtins/hooks/values.rs
index bb693880dd..f0c65c5b33 100644
--- a/crates/elephc-magician/src/interpreter/builtins/hooks/values.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/hooks/values.rs
@@ -201,6 +201,8 @@ pub(in crate::interpreter) enum EvalValuesHook {
Rad2deg,
/// Dispatches `rand(...)`.
Rand,
+ /// Dispatches `random_bytes(...)`.
+ RandomBytes,
/// Dispatches `random_int(...)`.
RandomInt,
/// Dispatches `round(...)`.
@@ -492,6 +494,7 @@ impl EvalValuesHook {
Self::Pow => two_args(evaluated_args, values, eval_pow_result),
Self::Rad2deg => one_arg(evaluated_args, values, eval_rad2deg_result),
Self::Rand => eval_rand_values_result(evaluated_args, values),
+ Self::RandomBytes => eval_random_bytes_values_result(evaluated_args, values),
Self::RandomInt => eval_random_int_values_result(evaluated_args, values),
Self::Round => match evaluated_args {
[value] => eval_round_result(*value, None, values),
@@ -529,6 +532,8 @@ impl EvalValuesHook {
Self::Sinh => one_arg(evaluated_args, values, eval_sinh_result),
Self::Slashes => one_arg(evaluated_args, values, |value, values| match name {
"addslashes" => eval_addslashes_result(value, values),
+ "escapeshellarg" => eval_escapeshellarg_result(value, values),
+ "escapeshellcmd" => eval_escapeshellcmd_result(value, values),
"stripslashes" => eval_stripslashes_result(value, values),
_ => Err(EvalStatus::RuntimeFatal),
}),
diff --git a/crates/elephc-magician/src/interpreter/builtins/math/mod.rs b/crates/elephc-magician/src/interpreter/builtins/math/mod.rs
index ac4de3582a..0443565c0d 100644
--- a/crates/elephc-magician/src/interpreter/builtins/math/mod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/math/mod.rs
@@ -33,6 +33,7 @@ mod mt_rand;
mod pi;
mod pow;
mod rand;
+mod random_bytes;
mod random_int;
mod rad2deg;
mod round;
@@ -68,6 +69,7 @@ pub(in crate::interpreter) use pi::*;
pub(in crate::interpreter) use pow::*;
pub(in crate::interpreter) use rad2deg::*;
pub(in crate::interpreter) use rand::*;
+pub(in crate::interpreter) use random_bytes::*;
pub(in crate::interpreter) use random_int::*;
pub(in crate::interpreter) use round::*;
pub(in crate::interpreter) use sin::*;
diff --git a/crates/elephc-magician/src/interpreter/builtins/math/random_bytes.rs b/crates/elephc-magician/src/interpreter/builtins/math/random_bytes.rs
new file mode 100644
index 0000000000..f32a1340f8
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/math/random_bytes.rs
@@ -0,0 +1,58 @@
+//! Purpose:
+//! Eval registry entry and implementation for `random_bytes`.
+//!
+//! Called from:
+//! - `crate::interpreter::builtins::hooks`.
+//!
+//! Key details:
+//! - Bytes come directly from the operating system CSPRNG and remain binary-safe.
+
+use super::super::super::*;
+
+eval_builtin! {
+ name: "random_bytes",
+ area: Math,
+ params: [length],
+ direct: RandomBytes,
+ values: RandomBytes,
+}
+
+/// Evaluates PHP `random_bytes()` with one strictly positive byte length.
+pub(in crate::interpreter) fn eval_builtin_random_bytes(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [length] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ let length = eval_expr(length, context, scope, values)?;
+ eval_random_bytes_result(length, values)
+}
+
+/// Dispatches a by-value `random_bytes()` call after argument binding.
+pub(in crate::interpreter) fn eval_random_bytes_values_result(
+ evaluated_args: &[RuntimeCellHandle],
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [length] = evaluated_args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ eval_random_bytes_result(*length, values)
+}
+
+/// Returns a binary-safe PHP string filled by the operating system CSPRNG.
+pub(in crate::interpreter) fn eval_random_bytes_result(
+ length: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let length = eval_int_value(length, values)?;
+ let length = usize::try_from(length)
+ .ok()
+ .filter(|length| *length > 0)
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ let mut bytes = vec![0_u8; length];
+ getrandom::getrandom(&mut bytes).map_err(|_| EvalStatus::RuntimeFatal)?;
+ values.string_bytes_value(&bytes)
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/math/random_int.rs b/crates/elephc-magician/src/interpreter/builtins/math/random_int.rs
index 0bc4846bdf..488c5627ab 100644
--- a/crates/elephc-magician/src/interpreter/builtins/math/random_int.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/math/random_int.rs
@@ -55,9 +55,13 @@ pub(in crate::interpreter) fn eval_random_int_result(
if min > max {
return Err(EvalStatus::RuntimeFatal);
}
- let width = (i128::from(max) - i128::from(min) + 1) as u128;
- let offset = (eval_random_u128() % width) as i128;
- let sampled = i128::from(min) + offset;
+ // Inclusive width, so the full PHP integer range stays representable: max - min
+ // is UINT64_MAX for random_int(PHP_INT_MIN, PHP_INT_MAX), where max - min + 1
+ // would wrap. The CSPRNG draw is rejection-sampled, matching PHP's guarantee
+ // that random_int() is both cryptographically secure and unbiased.
+ let umax = (i128::from(max) - i128::from(min)) as u128 as u64;
+ let offset = eval_csprng_range(umax)?;
+ let sampled = i128::from(min) + i128::from(offset);
let sampled = i64::try_from(sampled).map_err(|_| EvalStatus::RuntimeFatal)?;
values.int(sampled)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/mod.rs b/crates/elephc-magician/src/interpreter/builtins/mod.rs
index 25e111e44b..80f7c009b0 100644
--- a/crates/elephc-magician/src/interpreter/builtins/mod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/mod.rs
@@ -17,7 +17,7 @@ mod macros;
mod array;
mod class_metadata;
mod core;
-mod filesystem;
+pub(in crate::interpreter) mod filesystem;
mod formatting;
mod hooks;
mod json;
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/gethostbyaddr.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/gethostbyaddr.rs
index 751d9f28d7..9c8c0d6bc0 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/gethostbyaddr.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/gethostbyaddr.rs
@@ -5,7 +5,7 @@
//! - `crate::interpreter::builtins::network_env` direct and by-value dispatch.
//!
//! Key details:
-//! - libc resolver storage is copied before any subsequent resolver lookup can overwrite it.
+//! - OS resolver storage is copied before any subsequent resolver lookup can overwrite it.
use super::*;
@@ -41,21 +41,7 @@ pub(in crate::interpreter) fn eval_gethostbyaddr_result(
let Ok(ipv4) = ip_text.parse::() else {
return values.bool_value(false);
};
- let octets = ipv4.octets();
- let resolved = unsafe {
- // libc reads the stack-owned IPv4 octets during this call and returns
- // static resolver storage, which is copied before the next resolver call.
- let host = libc_gethostbyaddr(
- octets.as_ptr().cast::(),
- octets.len() as libc::socklen_t,
- libc::AF_INET,
- );
- if host.is_null() || (*host).h_name.is_null() {
- None
- } else {
- Some(CStr::from_ptr((*host).h_name).to_bytes().to_vec())
- }
- };
+ let resolved = eval_reverse_ipv4_name(ipv4.octets());
match resolved {
Some(name) if !name.is_empty() => values.string_bytes_value(&name),
_ => values.string(ip_text.as_ref()),
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/gethostname.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/gethostname.rs
index 2b4a545e60..bef5b3475f 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/gethostname.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/gethostname.rs
@@ -5,7 +5,7 @@
//! - `crate::interpreter::builtins::network_env` direct and by-value dispatch.
//!
//! Key details:
-//! - The libc hostname buffer is stack-owned and copied into a PHP string.
+//! - The OS hostname buffer is copied into a PHP string before returning.
use super::*;
@@ -28,25 +28,12 @@ pub(in crate::interpreter) fn eval_builtin_gethostname(
eval_gethostname_result(values)
}
-/// Reads the current host name through libc and returns an empty string on failure.
+/// Reads the current host name through the platform API and returns an empty string on failure.
pub(in crate::interpreter) fn eval_gethostname_result(
values: &mut impl RuntimeValueOps,
) -> Result {
- let mut buffer = [0 as libc::c_char; 256];
- let status = unsafe {
- // libc writes at most buffer.len() bytes into this stack buffer.
- libc::gethostname(buffer.as_mut_ptr(), buffer.len())
- };
- if status != 0 {
- return values.string("");
+ match eval_os_hostname() {
+ Some(hostname) => values.string_bytes_value(&hostname),
+ None => values.string(""),
}
- let length = buffer
- .iter()
- .position(|byte| *byte == 0)
- .unwrap_or(buffer.len());
- let hostname = buffer[..length]
- .iter()
- .map(|byte| *byte as u8)
- .collect::>();
- values.string_bytes_value(&hostname)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobyname.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobyname.rs
index 1a1982e1e9..7bbcb34835 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobyname.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobyname.rs
@@ -39,15 +39,10 @@ pub(in crate::interpreter) fn eval_getprotobyname_result(
let Some(protocol) = eval_lowercase_c_string(protocol, values)? else {
return values.bool_value(false);
};
- let entry = unsafe {
- // libc returns a process-global protoent; copy scalar fields before another lookup.
- libc_getprotobyname(protocol.as_ptr())
- };
- if entry.is_null() {
- return values.bool_value(false);
+ match eval_protocol_number(&protocol) {
+ Some(number) => values.int(i64::from(number)),
+ None => values.bool_value(false),
}
- let number = unsafe { (*entry).p_proto };
- values.int(i64::from(number))
}
@@ -64,20 +59,13 @@ pub(in crate::interpreter) fn eval_lowercase_c_string(
Ok(CString::new(bytes).ok())
}
-/// Copies a protoent canonical name into a PHP string or returns PHP false.
+/// Copies a protocol canonical name into a PHP string or returns PHP false.
pub(in crate::interpreter) fn eval_protoent_name_or_false(
- entry: *mut libc::protoent,
+ name: Option>,
values: &mut impl RuntimeValueOps,
) -> Result {
- if entry.is_null() {
- return values.bool_value(false);
+ match name {
+ Some(name) => values.string_bytes_value(&name),
+ None => values.bool_value(false),
}
- let name = unsafe {
- let name = (*entry).p_name;
- if name.is_null() {
- return values.bool_value(false);
- }
- CStr::from_ptr(name).to_bytes().to_vec()
- };
- values.string_bytes_value(&name)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobynumber.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobynumber.rs
index 0b71fb38bc..64f7a6e037 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobynumber.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/getprotobynumber.rs
@@ -40,9 +40,5 @@ pub(in crate::interpreter) fn eval_getprotobynumber_result(
let Ok(protocol) = libc::c_int::try_from(protocol) else {
return values.bool_value(false);
};
- let entry = unsafe {
- // libc returns a process-global protoent; copy the name before another lookup.
- libc_getprotobynumber(protocol)
- };
- eval_protoent_name_or_false(entry, values)
+ eval_protoent_name_or_false(eval_protocol_name(protocol), values)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyname.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyname.rs
index 478e58b48d..8b2decd244 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyname.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyname.rs
@@ -44,32 +44,20 @@ pub(in crate::interpreter) fn eval_getservbyname_result(
let Some(protocol) = eval_lowercase_c_string(protocol, values)? else {
return values.bool_value(false);
};
- let entry = unsafe {
- // libc returns a process-global servent; copy scalar fields before another lookup.
- libc_getservbyname(service.as_ptr(), protocol.as_ptr())
- };
- if entry.is_null() {
- return values.bool_value(false);
+ match eval_service_port(&service, &protocol) {
+ Some(port) => values.int(i64::from(port)),
+ None => values.bool_value(false),
}
- let port = unsafe { u16::from_be((*entry).s_port as u16) };
- values.int(i64::from(port))
}
-/// Copies a servent canonical name into a PHP string or returns PHP false.
+/// Copies a service canonical name into a PHP string or returns PHP false.
pub(in crate::interpreter) fn eval_servent_name_or_false(
- entry: *mut libc::servent,
+ name: Option>,
values: &mut impl RuntimeValueOps,
) -> Result {
- if entry.is_null() {
- return values.bool_value(false);
+ match name {
+ Some(name) => values.string_bytes_value(&name),
+ None => values.bool_value(false),
}
- let name = unsafe {
- let name = (*entry).s_name;
- if name.is_null() {
- return values.bool_value(false);
- }
- CStr::from_ptr(name).to_bytes().to_vec()
- };
- values.string_bytes_value(&name)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyport.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyport.rs
index b3adc46ae3..f84f34e4a5 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyport.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/getservbyport.rs
@@ -45,10 +45,5 @@ pub(in crate::interpreter) fn eval_getservbyport_result(
let Some(protocol) = eval_lowercase_c_string(protocol, values)? else {
return values.bool_value(false);
};
- let network_port = port.to_be() as libc::c_int;
- let entry = unsafe {
- // libc returns a process-global servent; copy the name before another lookup.
- libc_getservbyport(network_port, protocol.as_ptr())
- };
- eval_servent_name_or_false(entry, values)
+ eval_servent_name_or_false(eval_service_name(port, &protocol), values)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/network_env/php_uname.rs b/crates/elephc-magician/src/interpreter/builtins/network_env/php_uname.rs
index 27bc934606..db4b362100 100644
--- a/crates/elephc-magician/src/interpreter/builtins/network_env/php_uname.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/network_env/php_uname.rs
@@ -5,7 +5,7 @@
//! - `crate::interpreter::builtins::network_env` direct and by-value dispatch.
//!
//! Key details:
-//! - libc uname fields are copied into PHP strings before formatting the requested mode.
+//! - Platform uname fields are copied into PHP strings before formatting the requested mode.
use super::*;
@@ -52,28 +52,20 @@ pub(in crate::interpreter) fn eval_php_uname_result(
None => b'a',
};
- let mut utsname = std::mem::MaybeUninit::::zeroed();
- let status = unsafe {
- // libc writes all uname fields into the stack-owned utsname buffer.
- libc::uname(utsname.as_mut_ptr())
- };
- if status != 0 {
+ let Some(uname) = eval_os_uname() else {
return values.string("");
- }
- let utsname = unsafe {
- // `uname` succeeded, so libc initialized the full `utsname` structure.
- utsname.assume_init()
};
- let sysname = eval_uname_field_bytes(&utsname.sysname);
- let nodename = eval_uname_field_bytes(&utsname.nodename);
- let release = eval_uname_field_bytes(&utsname.release);
- let version = eval_uname_field_bytes(&utsname.version);
- let machine = eval_uname_field_bytes(&utsname.machine);
match mode {
b'a' => {
let mut output = Vec::new();
- for field in [&sysname, &nodename, &release, &version, &machine] {
+ for field in [
+ &uname.sysname,
+ &uname.nodename,
+ &uname.release,
+ &uname.version,
+ &uname.machine,
+ ] {
if !output.is_empty() {
output.push(b' ');
}
@@ -81,20 +73,11 @@ pub(in crate::interpreter) fn eval_php_uname_result(
}
values.string_bytes_value(&output)
}
- b's' => values.string_bytes_value(&sysname),
- b'n' => values.string_bytes_value(&nodename),
- b'r' => values.string_bytes_value(&release),
- b'v' => values.string_bytes_value(&version),
- b'm' => values.string_bytes_value(&machine),
+ b's' => values.string_bytes_value(&uname.sysname),
+ b'n' => values.string_bytes_value(&uname.nodename),
+ b'r' => values.string_bytes_value(&uname.release),
+ b'v' => values.string_bytes_value(&uname.version),
+ b'm' => values.string_bytes_value(&uname.machine),
_ => Err(EvalStatus::RuntimeFatal),
}
}
-
-/// Copies one NUL-terminated `utsname` field into raw PHP string bytes.
-pub(in crate::interpreter) fn eval_uname_field_bytes(field: &[libc::c_char]) -> Vec {
- let length = field
- .iter()
- .position(|byte| *byte == 0)
- .unwrap_or(field.len());
- field[..length].iter().map(|byte| *byte as u8).collect()
-}
diff --git a/crates/elephc-magician/src/interpreter/builtins/random.rs b/crates/elephc-magician/src/interpreter/builtins/random.rs
index ef646be040..84833ee72e 100644
--- a/crates/elephc-magician/src/interpreter/builtins/random.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/random.rs
@@ -6,8 +6,12 @@
//! - `crate::interpreter::builtins::array` randomizing builtins.
//!
//! Key details:
-//! - This is eval-local, process-local, and non-cryptographic; PHP-visible
-//! builtin owners decide range and key semantics.
+//! - `eval_random_u128` is eval-local, process-local, and non-cryptographic; it
+//! backs `rand`, `mt_rand`, `shuffle` and `array_rand`, which PHP itself does not
+//! promise to make cryptographically secure.
+//! - `random_int` is different: PHP guarantees a CSPRNG and raises when none is
+//! available, so it goes through `eval_csprng_range`, which draws from the OS
+//! entropy source and rejection-samples to stay unbiased.
use super::super::*;
@@ -26,3 +30,38 @@ pub(in crate::interpreter) fn eval_random_u128() -> u128 {
value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
+
+/// Draws one uint64 from the operating system's CSPRNG.
+///
+/// PHP's `random_int()` is specified to fail rather than fall back to a weaker
+/// source, so an unavailable entropy source becomes a runtime fatal here instead
+/// of silently degrading to `eval_random_u128`.
+fn eval_csprng_u64() -> Result {
+ let mut bytes = [0u8; 8];
+ getrandom::getrandom(&mut bytes).map_err(|_| EvalStatus::RuntimeFatal)?;
+ Ok(u64::from_le_bytes(bytes))
+}
+
+/// Returns a uniform value in `[0, umax]` drawn from the OS CSPRNG.
+///
+/// Mirrors php-src `php_random_range64` (ext/random/random.c): `UINT64_MAX` is
+/// returned unreduced, a power-of-two count is masked, and anything else is
+/// rejection-sampled against `UINT64_MAX - (UINT64_MAX % count) - 1`. A plain
+/// `draw % count` would be modulo-biased toward the low end of the range, which is
+/// exactly what PHP takes care to avoid.
+pub(in crate::interpreter) fn eval_csprng_range(umax: u64) -> Result {
+ if umax == u64::MAX {
+ return eval_csprng_u64();
+ }
+ let count = umax + 1;
+ if count.is_power_of_two() {
+ return Ok(eval_csprng_u64()? & (count - 1));
+ }
+ let limit = u64::MAX - (u64::MAX % count) - 1;
+ loop {
+ let candidate = eval_csprng_u64()?;
+ if candidate <= limit {
+ return Ok(candidate % count);
+ }
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/registry/tests/direct_hooks.rs b/crates/elephc-magician/src/interpreter/builtins/registry/tests/direct_hooks.rs
index fe28c54bc5..46298a8627 100644
--- a/crates/elephc-magician/src/interpreter/builtins/registry/tests/direct_hooks.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/registry/tests/direct_hooks.rs
@@ -40,6 +40,7 @@ use super::*;
"natcasesort",
"natsort",
"pfsockopen",
+ "proc_open",
"rsort",
"settype",
"shuffle",
diff --git a/crates/elephc-magician/src/interpreter/builtins/string/escapeshellarg.rs b/crates/elephc-magician/src/interpreter/builtins/string/escapeshellarg.rs
new file mode 100644
index 0000000000..3ebd96ea37
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/string/escapeshellarg.rs
@@ -0,0 +1,16 @@
+//! Purpose:
+//! Declares the eval-visible `escapeshellarg` builtin.
+//!
+//! Called from:
+//! - The registry's shared Slashes hook.
+//!
+//! Key details:
+//! - The leaf delegates platform-specific escaping to `shell_escape`.
+
+eval_builtin! {
+ name: "escapeshellarg",
+ area: String,
+ params: [arg],
+ direct: Slashes,
+ values: Slashes,
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/string/escapeshellcmd.rs b/crates/elephc-magician/src/interpreter/builtins/string/escapeshellcmd.rs
new file mode 100644
index 0000000000..d6b8c2d2a7
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/string/escapeshellcmd.rs
@@ -0,0 +1,16 @@
+//! Purpose:
+//! Declares the eval-visible `escapeshellcmd` builtin.
+//!
+//! Called from:
+//! - The registry's shared Slashes hook.
+//!
+//! Key details:
+//! - The leaf delegates platform-specific escaping to `shell_escape`.
+
+eval_builtin! {
+ name: "escapeshellcmd",
+ area: String,
+ params: [command],
+ direct: Slashes,
+ values: Slashes,
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/string/mb_strlen.rs b/crates/elephc-magician/src/interpreter/builtins/string/mb_strlen.rs
index c748c3823c..61febfcc11 100644
--- a/crates/elephc-magician/src/interpreter/builtins/string/mb_strlen.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/string/mb_strlen.rs
@@ -26,7 +26,11 @@ eval_builtin! {
#[cfg_attr(target_os = "macos", link(name = "iconv"))]
unsafe extern "C" {
+ /// Opens an iconv conversion descriptor for the requested encoding pair.
+ #[cfg_attr(windows, link_name = "libiconv_open")]
fn iconv_open(tocode: *const c_char, fromcode: *const c_char) -> *mut c_void;
+ /// Converts an input byte buffer through an open iconv descriptor.
+ #[cfg_attr(windows, link_name = "libiconv")]
fn iconv(
cd: *mut c_void,
inbuf: *mut *mut c_char,
@@ -34,6 +38,8 @@ unsafe extern "C" {
outbuf: *mut *mut c_char,
outbytesleft: *mut usize,
) -> usize;
+ /// Closes an iconv conversion descriptor and releases its native resources.
+ #[cfg_attr(windows, link_name = "libiconv_close")]
fn iconv_close(cd: *mut c_void) -> i32;
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/string/mod.rs b/crates/elephc-magician/src/interpreter/builtins/string/mod.rs
index 82e8bf27e1..97704452c4 100644
--- a/crates/elephc-magician/src/interpreter/builtins/string/mod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/string/mod.rs
@@ -21,6 +21,8 @@ mod ctype_alpha;
mod ctype_digit;
mod ctype_space;
mod explode;
+mod escapeshellarg;
+mod escapeshellcmd;
mod grapheme_strrev;
mod gzcompress;
mod gzdeflate;
@@ -50,6 +52,7 @@ mod rawurldecode;
mod rawurlencode;
mod rtrim;
mod sha1;
+mod shell_escape;
mod str_contains;
mod str_ends_with;
mod str_ireplace;
@@ -94,6 +97,7 @@ pub(in crate::interpreter) use ctype_alpha::*;
pub(in crate::interpreter) use ctype_digit::*;
pub(in crate::interpreter) use ctype_space::*;
pub(in crate::interpreter) use explode::*;
+pub(in crate::interpreter) use shell_escape::*;
pub(in crate::interpreter) use grapheme_strrev::*;
pub(in crate::interpreter) use gzcompress::*;
pub(in crate::interpreter) use gzdeflate::*;
diff --git a/crates/elephc-magician/src/interpreter/builtins/string/shell_escape.rs b/crates/elephc-magician/src/interpreter/builtins/string/shell_escape.rs
new file mode 100644
index 0000000000..2ffd69055a
--- /dev/null
+++ b/crates/elephc-magician/src/interpreter/builtins/string/shell_escape.rs
@@ -0,0 +1,230 @@
+//! Purpose:
+//! Implements eval-time PHP shell escaping for argument and command fragments.
+//!
+//! Called from:
+//! - The builtin hook dispatch for `escapeshellarg()` and `escapeshellcmd()`.
+//!
+//! Key details:
+//! - NUL is rejected before shell syntax is produced.
+//! - Windows and POSIX use deliberately distinct quoting rules.
+
+use super::super::super::*;
+
+/// Evaluates a direct `escapeshellarg(arg)` call before platform-specific escaping.
+pub(in crate::interpreter) fn eval_builtin_escapeshellarg(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [value] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ let value = eval_expr(value, context, scope, values)?;
+ eval_escapeshellarg_result(value, values)
+}
+
+/// Evaluates a direct `escapeshellcmd(command)` call before platform-specific escaping.
+pub(in crate::interpreter) fn eval_builtin_escapeshellcmd(
+ args: &[EvalExpr],
+ context: &mut ElephcEvalContext,
+ scope: &mut ElephcEvalScope,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let [value] = args else {
+ return Err(EvalStatus::RuntimeFatal);
+ };
+ let value = eval_expr(value, context, scope, values)?;
+ eval_escapeshellcmd_result(value, values)
+}
+
+/// Escapes one string as a single shell argument using the host platform's PHP rules.
+pub(in crate::interpreter) fn eval_escapeshellarg_result(
+ value: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let bytes = values.string_bytes(value)?;
+ if bytes.contains(&0) {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ #[cfg(windows)]
+ let escaped = eval_windows_shell_arg(&bytes);
+ #[cfg(not(windows))]
+ let escaped = eval_posix_shell_arg(&bytes);
+ values.string_bytes_value(&escaped)
+}
+
+/// Escapes one string for interpolation into a shell command using host-specific metacharacters.
+pub(in crate::interpreter) fn eval_escapeshellcmd_result(
+ value: RuntimeCellHandle,
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let bytes = values.string_bytes(value)?;
+ if bytes.contains(&0) {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ #[cfg(windows)]
+ let escaped = eval_windows_shell_cmd(&bytes);
+ #[cfg(not(windows))]
+ let escaped = eval_posix_shell_cmd(&bytes);
+ values.string_bytes_value(&escaped)
+}
+
+/// Quotes one POSIX shell argument while preserving embedded apostrophes.
+#[cfg(not(windows))]
+fn eval_posix_shell_arg(bytes: &[u8]) -> Vec {
+ let mut escaped = Vec::with_capacity(bytes.len() + 2);
+ escaped.push(b'\'');
+ for &byte in bytes {
+ if byte == 0xff {
+ continue;
+ }
+ if byte == b'\'' {
+ escaped.extend_from_slice(b"'\\''");
+ } else {
+ escaped.push(byte);
+ }
+ }
+ escaped.push(b'\'');
+ escaped
+}
+
+/// Quotes one Windows command-shell argument, neutralizing delayed-expansion syntax.
+///
+/// Compiled on every host so the quoting rules stay unit-testable; only the
+/// Windows build routes `escapeshellarg()` through it, hence the dead-code
+/// allowance elsewhere.
+#[cfg_attr(not(windows), allow(dead_code))]
+pub(super) fn eval_windows_shell_arg(bytes: &[u8]) -> Vec {
+ let mut escaped = Vec::with_capacity(bytes.len() + 2);
+ let mut trailing_backslashes = 0_usize;
+ escaped.push(b'"');
+ for &byte in bytes {
+ if byte == 0xff {
+ continue;
+ }
+ if byte == b'\\' {
+ trailing_backslashes += 1;
+ } else {
+ trailing_backslashes = 0;
+ }
+ if matches!(byte, b'"' | b'%' | b'!') {
+ // php REPLACES these bytes with a space and does not re-emit them:
+ // ext/standard/exec.c php_escape_shell_arg breaks out of the switch
+ // before the `default` arm that would append the original byte.
+ // Emitting both let a '"' close the quoting early, so
+ // escapeshellarg('a"b') produced "a "b" and the tail escaped the
+ // argument -- the injection this quoting exists to prevent.
+ escaped.push(b' ');
+ continue;
+ }
+ escaped.push(byte);
+ }
+ if trailing_backslashes % 2 == 1 {
+ escaped.push(b'\\');
+ }
+ escaped.push(b'"');
+ escaped
+}
+
+/// Backslash-escapes POSIX shell metacharacters without adding argument quotes.
+#[cfg(not(windows))]
+fn eval_posix_shell_cmd(bytes: &[u8]) -> Vec {
+ eval_posix_shell_cmd_with_quote_pairs(bytes)
+}
+
+/// Caret-escapes Windows command-shell metacharacters without adding argument quotes.
+#[cfg(windows)]
+fn eval_windows_shell_cmd(bytes: &[u8]) -> Vec {
+ eval_windows_shell_cmd_with_escape(bytes, b'^')
+}
+
+/// Applies Windows command-shell metacharacter escaping with a caret prefix.
+#[cfg(windows)]
+fn eval_windows_shell_cmd_with_escape(bytes: &[u8], escape: u8) -> Vec {
+ let mut escaped = Vec::with_capacity(bytes.len() * 2);
+ for &byte in bytes {
+ if byte == 0xff {
+ continue;
+ }
+ let special = matches!(byte, b'#' | b'&' | b';' | b'`' | b'|' | b'*' | b'?' | b'~' | b'<' | b'>' | b'^' | b'(' | b')' | b'[' | b']' | b'{' | b'}' | b'$' | b'\\' | b'\n');
+ if special || matches!(byte, b'%' | b'!' | b'"' | b'\'') {
+ escaped.push(escape);
+ }
+ escaped.push(byte);
+ }
+ escaped
+}
+
+/// Backslash-escapes POSIX command metacharacters while retaining matched quote pairs.
+#[cfg(not(windows))]
+fn eval_posix_shell_cmd_with_quote_pairs(bytes: &[u8]) -> Vec {
+ let mut escaped = Vec::with_capacity(bytes.len() * 2);
+ let mut paired_quote_end = None;
+ for (index, &byte) in bytes.iter().enumerate() {
+ if byte == 0xff {
+ continue;
+ }
+ if matches!(byte, b'\'' | b'"') {
+ if paired_quote_end == Some(index) {
+ paired_quote_end = None;
+ escaped.push(byte);
+ continue;
+ }
+ if paired_quote_end.is_none()
+ && bytes[index + 1..].iter().position(|candidate| *candidate == byte).is_some()
+ {
+ paired_quote_end = bytes[index + 1..]
+ .iter()
+ .position(|candidate| *candidate == byte)
+ .map(|offset| index + offset + 1);
+ escaped.push(byte);
+ continue;
+ }
+ escaped.push(b'\\');
+ escaped.push(byte);
+ continue;
+ }
+ let special = matches!(byte, b'#' | b'&' | b';' | b'`' | b'|' | b'*' | b'?' | b'~' | b'<' | b'>' | b'^' | b'(' | b')' | b'[' | b']' | b'{' | b'}' | b'$' | b'\\' | b'\n');
+ if special {
+ escaped.push(b'\\');
+ }
+ escaped.push(byte);
+ }
+ escaped
+}
+
+#[cfg(test)]
+mod windows_shell_arg_tests {
+ use super::eval_windows_shell_arg;
+
+ /// Reproduces the argument-injection shape: php replaces `"`, `%` and `!` with a
+ /// space (ext/standard/exec.c php_escape_shell_arg breaks before the arm that
+ /// re-emits the byte), so none of them can survive into the quoted argument.
+ /// Emitting the byte as well let a quote close the quoting early.
+ #[test]
+ fn replaces_quote_percent_and_bang_instead_of_keeping_them() {
+ assert_eq!(eval_windows_shell_arg(br#"a"b"#), br#""a b""#);
+ assert_eq!(eval_windows_shell_arg(b"%PATH%"), br#"" PATH ""#);
+ assert_eq!(eval_windows_shell_arg(b"a!b!"), br#""a b ""#);
+ assert_eq!(eval_windows_shell_arg(br#"" & calc.exe & ""#), br#"" & calc.exe & ""#);
+ }
+
+ /// Ordinary bytes are quoted verbatim, and an empty argument still produces a
+ /// quoted empty string rather than nothing.
+ #[test]
+ fn quotes_ordinary_arguments_verbatim() {
+ assert_eq!(eval_windows_shell_arg(b"plain"), br#""plain""#);
+ assert_eq!(eval_windows_shell_arg(b"with space"), br#""with space""#);
+ assert_eq!(eval_windows_shell_arg(b""), br#""""#);
+ }
+
+ /// A trailing backslash run is padded to an even length so the closing quote is
+ /// not itself escaped, matching php's post-loop fixup.
+ #[test]
+ fn pads_an_odd_trailing_backslash_run() {
+ assert_eq!(eval_windows_shell_arg(br"a\"), br#""a\\""#);
+ assert_eq!(eval_windows_shell_arg(br"a\\"), br#""a\\""#);
+ assert_eq!(eval_windows_shell_arg(br"a\\\"), br#""a\\\\""#);
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/time/date.rs b/crates/elephc-magician/src/interpreter/builtins/time/date.rs
index 7adcce14de..83483010ff 100644
--- a/crates/elephc-magician/src/interpreter/builtins/time/date.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/time/date.rs
@@ -6,9 +6,11 @@
//!
//! Key details:
//! - `gmdate` calls this file for shared formatting and UTC/local timestamp conversion.
+//! - Unix libc and the Windows 64-bit CRT are hidden behind the same timestamp helpers.
-use std::os::unix::ffi::OsStrExt;
use std::sync::Mutex;
+#[cfg(unix)]
+use std::os::unix::ffi::OsStrExt;
use super::super::*;
use super::*;
@@ -25,11 +27,31 @@ eval_builtin! {
static EVAL_TZ_MUTEX: Mutex<()> = Mutex::new(());
+#[cfg(unix)]
unsafe extern "C" {
/// Re-reads libc's process-global timezone environment.
fn tzset();
}
+#[cfg(windows)]
+unsafe extern "C" {
+ /// Converts a 64-bit Unix timestamp into local broken-down time through the Windows CRT.
+ #[link_name = "_localtime64_s"]
+ fn windows_localtime64_s(output: *mut libc::tm, timestamp: *const i64) -> libc::c_int;
+
+ /// Converts a 64-bit Unix timestamp into UTC broken-down time through the Windows CRT.
+ #[link_name = "_gmtime64_s"]
+ fn windows_gmtime64_s(output: *mut libc::tm, timestamp: *const i64) -> libc::c_int;
+
+ /// Sets or removes one Windows CRT environment variable.
+ #[link_name = "_putenv_s"]
+ fn windows_putenv_s(name: *const libc::c_char, value: *const libc::c_char) -> libc::c_int;
+
+ /// Re-reads the Windows CRT process-global timezone environment.
+ #[link_name = "_tzset"]
+ fn windows_tzset();
+}
+
/// Evaluates PHP `date($format, $timestamp = time())` for the eval subset.
pub(in crate::interpreter) fn eval_builtin_date(
args: &[EvalExpr],
@@ -95,6 +117,8 @@ pub(in crate::interpreter) fn eval_context_localtime(
/// Converts one Unix timestamp to process-local broken-down time through libc.
pub(in crate::interpreter) fn eval_localtime(timestamp: i64) -> Result {
+ #[cfg(unix)]
+ {
let raw: libc::time_t = timestamp.try_into().map_err(|_| EvalStatus::RuntimeFatal)?;
let mut tm = MaybeUninit::::uninit();
let result = unsafe { libc::localtime_r(&raw, tm.as_mut_ptr()) };
@@ -102,10 +126,22 @@ pub(in crate::interpreter) fn eval_localtime(timestamp: i64) -> Result::uninit();
+ let status = unsafe { windows_localtime64_s(tm.as_mut_ptr(), ×tamp) };
+ if status != 0 {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ Ok(unsafe { tm.assume_init() })
+ }
}
/// Converts one Unix timestamp to UTC broken-down time through libc.
pub(in crate::interpreter) fn eval_gmtime(timestamp: i64) -> Result {
+ #[cfg(unix)]
+ {
let raw: libc::time_t = timestamp.try_into().map_err(|_| EvalStatus::RuntimeFatal)?;
let mut tm = MaybeUninit::::uninit();
let result = unsafe { libc::gmtime_r(&raw, tm.as_mut_ptr()) };
@@ -113,6 +149,16 @@ pub(in crate::interpreter) fn eval_gmtime(timestamp: i64) -> Result::uninit();
+ let status = unsafe { windows_gmtime64_s(tm.as_mut_ptr(), ×tamp) };
+ if status != 0 {
+ return Err(EvalStatus::RuntimeFatal);
+ }
+ Ok(unsafe { tm.assume_init() })
+ }
}
/// Runs one libc timezone-sensitive operation under the eval context timezone.
@@ -123,9 +169,16 @@ pub(in crate::interpreter) fn eval_with_timezone(
let _guard = EVAL_TZ_MUTEX
.lock()
.map_err(|_| EvalStatus::RuntimeFatal)?;
+ #[cfg(unix)]
let previous = std::env::var_os("TZ")
.map(|value| CString::new(value.as_bytes()).map_err(|_| EvalStatus::RuntimeFatal))
.transpose()?;
+ #[cfg(windows)]
+ let previous = std::env::var_os("TZ")
+ .map(|value| {
+ CString::new(value.to_string_lossy().as_bytes()).map_err(|_| EvalStatus::RuntimeFatal)
+ })
+ .transpose()?;
eval_apply_process_timezone(timezone)?;
let result = operation();
eval_restore_process_timezone(previous.as_ref())?;
@@ -134,28 +187,36 @@ pub(in crate::interpreter) fn eval_with_timezone(
/// Applies one timezone identifier to libc's process-global timezone state.
fn eval_apply_process_timezone(timezone: &str) -> Result<(), EvalStatus> {
- let key = CString::new("TZ").map_err(|_| EvalStatus::RuntimeFatal)?;
- let value = CString::new(timezone).map_err(|_| EvalStatus::RuntimeFatal)?;
- let status = unsafe { libc::setenv(key.as_ptr(), value.as_ptr(), 1) };
- if status != 0 {
- return Err(EvalStatus::RuntimeFatal);
- }
- unsafe { tzset() };
- Ok(())
+ let timezone = CString::new(timezone).map_err(|_| EvalStatus::RuntimeFatal)?;
+ eval_set_process_timezone(Some(timezone.as_c_str()))
}
/// Restores the process timezone that was active before an eval-local conversion.
fn eval_restore_process_timezone(previous: Option<&CString>) -> Result<(), EvalStatus> {
+ eval_set_process_timezone(previous.map(|value| value.as_c_str()))
+}
+
+/// Updates the platform CRT timezone environment and refreshes its cached timezone state.
+fn eval_set_process_timezone(timezone: Option<&CStr>) -> Result<(), EvalStatus> {
let key = CString::new("TZ").map_err(|_| EvalStatus::RuntimeFatal)?;
- let status = if let Some(value) = previous {
- unsafe { libc::setenv(key.as_ptr(), value.as_ptr(), 1) }
- } else {
- unsafe { libc::unsetenv(key.as_ptr()) }
+ let empty = CString::new("").map_err(|_| EvalStatus::RuntimeFatal)?;
+ let value = timezone.unwrap_or(empty.as_c_str());
+ #[cfg(unix)]
+ let status = match timezone {
+ Some(_) => unsafe { libc::setenv(key.as_ptr(), value.as_ptr(), 1) },
+ None => unsafe { libc::unsetenv(key.as_ptr()) },
};
+ #[cfg(windows)]
+ let status = unsafe { windows_putenv_s(key.as_ptr(), value.as_ptr()) };
if status != 0 {
return Err(EvalStatus::RuntimeFatal);
}
+ #[cfg(unix)]
unsafe { tzset() };
+ #[cfg(windows)]
+ unsafe {
+ windows_tzset();
+ }
Ok(())
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/time/hrtime.rs b/crates/elephc-magician/src/interpreter/builtins/time/hrtime.rs
index 87f4747462..f2d3c58338 100644
--- a/crates/elephc-magician/src/interpreter/builtins/time/hrtime.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/time/hrtime.rs
@@ -5,10 +5,13 @@
//! - `crate::interpreter::builtins::time` direct and by-value dispatch.
//!
//! Key details:
-//! - Monotonic time is returned as nanoseconds or `[seconds, nanoseconds]`.
+//! - Rust's platform monotonic clock is returned as nanoseconds or
+//! `[seconds, nanoseconds]` without depending on target-specific libc APIs.
use super::super::super::*;
use super::super::*;
+use std::sync::OnceLock;
+use std::time::Instant;
use super::super::spec::EvalBuiltinDefaultValue;
@@ -20,6 +23,8 @@ eval_builtin! {
values: Time,
}
+static EVAL_MONOTONIC_ORIGIN: OnceLock = OnceLock::new();
+
/// Evaluates PHP `hrtime($as_number = false)`.
pub(in crate::interpreter) fn eval_builtin_hrtime(
args: &[EvalExpr],
@@ -61,11 +66,10 @@ pub(in crate::interpreter) fn eval_hrtime_result(
/// Reads the monotonic clock in whole seconds and nanoseconds.
fn eval_monotonic_time() -> Result<(i64, i64), EvalStatus> {
- let mut timespec = MaybeUninit::::uninit();
- let status = unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, timespec.as_mut_ptr()) };
- if status != 0 {
- return Err(EvalStatus::RuntimeFatal);
- }
- let timespec = unsafe { timespec.assume_init() };
- Ok((timespec.tv_sec, timespec.tv_nsec))
+ let elapsed = EVAL_MONOTONIC_ORIGIN.get_or_init(Instant::now).elapsed();
+ let seconds = i64::try_from(elapsed.as_secs())
+ .map_err(|_| EvalStatus::RuntimeFatal)?
+ .checked_add(1)
+ .ok_or(EvalStatus::RuntimeFatal)?;
+ Ok((seconds, i64::from(elapsed.subsec_nanos())))
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/time/microtime.rs b/crates/elephc-magician/src/interpreter/builtins/time/microtime.rs
index 06d13a50a9..6cac605f45 100644
--- a/crates/elephc-magician/src/interpreter/builtins/time/microtime.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/time/microtime.rs
@@ -27,10 +27,14 @@ pub(in crate::interpreter) fn eval_builtin_microtime(
values: &mut impl RuntimeValueOps,
) -> Result {
match args {
- [] => eval_microtime_result(values),
+ [] => eval_microtime_string_result(values),
[as_float] => {
- let _ = eval_expr(as_float, context, scope, values)?;
- eval_microtime_result(values)
+ let flag = eval_expr(as_float, context, scope, values)?;
+ if values.truthy(flag)? {
+ eval_microtime_result(values)
+ } else {
+ eval_microtime_string_result(values)
+ }
}
_ => Err(EvalStatus::RuntimeFatal),
}
@@ -47,3 +51,19 @@ pub(in crate::interpreter) fn eval_microtime_result(
let micros = f64::from(timestamp.subsec_micros()) / 1_000_000.0;
values.float(seconds + micros)
}
+
+/// Returns `microtime()`'s default string form: the sub-second fraction, a space,
+/// then the whole seconds.
+///
+/// php formats it as `"%.8F %ld"` over `tv_usec / 1e6` and `tv_sec`
+/// (ext/standard/microtime.c `_php_math_microtime`), producing values such as
+/// `"0.59125600 1784994272"`. Only a truthy argument selects the float form.
+pub(in crate::interpreter) fn eval_microtime_string_result(
+ values: &mut impl RuntimeValueOps,
+) -> Result {
+ let timestamp = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_err(|_| EvalStatus::RuntimeFatal)?;
+ let fraction = f64::from(timestamp.subsec_micros()) / 1_000_000.0;
+ values.string(&format!("{:.8} {}", fraction, timestamp.as_secs()))
+}
diff --git a/crates/elephc-magician/src/interpreter/builtins/time/mktime.rs b/crates/elephc-magician/src/interpreter/builtins/time/mktime.rs
index c99473ac0a..57925f21a0 100644
--- a/crates/elephc-magician/src/interpreter/builtins/time/mktime.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/time/mktime.rs
@@ -6,6 +6,7 @@
//!
//! Key details:
//! - `gmmktime` and `strtotime` reuse the timestamp conversion helpers from this file.
+//! - Windows uses the CRT's explicit 64-bit timestamp entry points.
use super::super::*;
use super::*;
@@ -18,6 +19,17 @@ eval_builtin! {
values: Time,
}
+#[cfg(windows)]
+unsafe extern "C" {
+ /// Converts local broken-down time into a 64-bit Unix timestamp through the Windows CRT.
+ #[link_name = "_mktime64"]
+ fn windows_mktime64(time: *mut libc::tm) -> i64;
+
+ /// Converts UTC broken-down time into a 64-bit Unix timestamp through the Windows CRT.
+ #[link_name = "_mkgmtime64"]
+ fn windows_mkgmtime64(time: *mut libc::tm) -> i64;
+}
+
/// Evaluates PHP `mktime(hour, minute, second, month, day, year)`.
pub(in crate::interpreter) fn eval_builtin_mktime(
args: &[EvalExpr],
@@ -110,7 +122,10 @@ pub(in crate::interpreter) fn eval_mktime_timestamp(
tm.tm_mday = day;
tm.tm_year = year - 1900;
tm.tm_isdst = -1;
+ #[cfg(unix)]
let timestamp = unsafe { libc::mktime(&mut tm) };
+ #[cfg(windows)]
+ let timestamp = unsafe { windows_mktime64(&mut tm) };
i64::try_from(timestamp).map_err(|_| EvalStatus::RuntimeFatal)
}
@@ -133,7 +148,10 @@ pub(in crate::interpreter) fn eval_gmmktime_timestamp(
tm.tm_mday = args.4;
tm.tm_year = args.5 - 1900;
tm.tm_isdst = 0;
+ #[cfg(unix)]
let timestamp = unsafe { libc::timegm(&mut tm) };
+ #[cfg(windows)]
+ let timestamp = unsafe { windows_mkgmtime64(&mut tm) };
i64::try_from(timestamp).map_err(|_| EvalStatus::RuntimeFatal)
}
diff --git a/crates/elephc-magician/src/interpreter/builtins/time/mod.rs b/crates/elephc-magician/src/interpreter/builtins/time/mod.rs
index e1a34f7cae..c14da4f91b 100644
--- a/crates/elephc-magician/src/interpreter/builtins/time/mod.rs
+++ b/crates/elephc-magician/src/interpreter/builtins/time/mod.rs
@@ -162,7 +162,17 @@ pub(in crate::interpreter) fn eval_time_values_result(
_ => Err(EvalStatus::RuntimeFatal),
},
"microtime" => match evaluated_args {
- [] | [_] => eval_microtime_result(values),
+ [] => eval_microtime_string_result(values),
+ // Only a truthy as_float selects the float form; php otherwise returns
+ // the "" string. This arm is the named-argument
+ // and call_user_func path, and it ignored the flag entirely.
+ [as_float] => {
+ if values.truthy(*as_float)? {
+ eval_microtime_result(values)
+ } else {
+ eval_microtime_string_result(values)
+ }
+ }
_ => Err(EvalStatus::RuntimeFatal),
},
"sleep" => {
diff --git a/crates/elephc-magician/src/interpreter/constant_eval.rs b/crates/elephc-magician/src/interpreter/constant_eval.rs
index 47bf66993c..788c0e99a5 100644
--- a/crates/elephc-magician/src/interpreter/constant_eval.rs
+++ b/crates/elephc-magician/src/interpreter/constant_eval.rs
@@ -147,22 +147,70 @@ pub(in crate::interpreter) fn eval_predefined_constant_value(
"INF" => Some(EvalPredefinedConstant::Float(f64::INFINITY)),
"NAN" => Some(EvalPredefinedConstant::Float(f64::NAN)),
"PHP_INT_MAX" => Some(EvalPredefinedConstant::Int(i64::MAX)),
- "PHP_EOL" => Some(EvalPredefinedConstant::String("\n")),
+ "PHP_EOL" => Some(EvalPredefinedConstant::String(eval_php_eol())),
"PHP_OS" => Some(EvalPredefinedConstant::String(eval_php_os_name())),
- "DIRECTORY_SEPARATOR" => Some(EvalPredefinedConstant::String("/")),
+ "PHP_OS_FAMILY" => Some(EvalPredefinedConstant::String(eval_php_os_family())),
+ "DIRECTORY_SEPARATOR" => Some(EvalPredefinedConstant::String(eval_directory_separator())),
+ "PATH_SEPARATOR" => Some(EvalPredefinedConstant::String(eval_path_separator())),
_ => None,
}
}
-/// Returns the PHP OS constant for the host platform running the eval bridge.
+/// Returns `PHP_OS` for the platform this interpreter was built for.
+///
+/// The interpreter is cross-compiled with the program, so `cfg!(target_os)` names
+/// the target rather than the build host. The Windows arm was missing, which made
+/// `eval('return PHP_OS;')` answer "Linux" inside a Windows binary whose compiled
+/// `PHP_OS` said "WINNT" — the same program disagreeing with itself.
fn eval_php_os_name() -> &'static str {
- if cfg!(target_os = "macos") {
+ if cfg!(target_os = "windows") {
+ "WINNT"
+ } else if cfg!(target_os = "macos") {
"Darwin"
} else {
"Linux"
}
}
+/// Returns `PHP_OS_FAMILY` for the platform this interpreter was built for.
+fn eval_php_os_family() -> &'static str {
+ if cfg!(target_os = "windows") {
+ "Windows"
+ } else if cfg!(target_os = "macos") {
+ "Darwin"
+ } else {
+ "Linux"
+ }
+}
+
+/// Returns `PHP_EOL`: `"\r\n"` on Windows (main/php.h), `"\n"` elsewhere.
+fn eval_php_eol() -> &'static str {
+ if cfg!(target_os = "windows") {
+ "\r\n"
+ } else {
+ "\n"
+ }
+}
+
+/// Returns `DIRECTORY_SEPARATOR`: `DEFAULT_SLASH` is a backslash on Windows
+/// (Zend/zend_virtual_cwd.h) and a forward slash everywhere else.
+fn eval_directory_separator() -> &'static str {
+ if cfg!(target_os = "windows") {
+ "\\"
+ } else {
+ "/"
+ }
+}
+
+/// Returns `PATH_SEPARATOR`: `";"` on Windows, `":"` elsewhere.
+fn eval_path_separator() -> &'static str {
+ if cfg!(target_os = "windows") {
+ ";"
+ } else {
+ ":"
+ }
+}
+
/// Resolves one eval magic constant against fragment and dynamic-call metadata.
pub(super) fn eval_magic_const(
magic: &EvalMagicConst,
@@ -190,3 +238,53 @@ pub(super) fn eval_magic_const(
EvalMagicConst::Trait => values.string(context.current_magic_trait().unwrap_or("")),
}
}
+
+#[cfg(test)]
+mod platform_constant_tests {
+ use super::{
+ eval_directory_separator, eval_path_separator, eval_php_eol, eval_php_os_family,
+ eval_php_os_name,
+ };
+
+ /// Verifies the interpreter reports the constants of the platform it was built
+ /// for, so an eval fragment cannot contradict the compiled constants in the same
+ /// binary. The Windows arms were missing entirely, which made a Windows program
+ /// answer PHP_OS "Linux" from inside eval() while its compiled PHP_OS said
+ /// "WINNT".
+ #[test]
+ fn reports_the_target_platform_constants() {
+ if cfg!(target_os = "windows") {
+ assert_eq!(eval_php_os_name(), "WINNT");
+ assert_eq!(eval_php_os_family(), "Windows");
+ assert_eq!(eval_php_eol(), "\r\n");
+ assert_eq!(eval_directory_separator(), "\\");
+ assert_eq!(eval_path_separator(), ";");
+ } else if cfg!(target_os = "macos") {
+ assert_eq!(eval_php_os_name(), "Darwin");
+ assert_eq!(eval_php_os_family(), "Darwin");
+ assert_eq!(eval_php_eol(), "\n");
+ assert_eq!(eval_directory_separator(), "/");
+ assert_eq!(eval_path_separator(), ":");
+ } else {
+ assert_eq!(eval_php_os_name(), "Linux");
+ assert_eq!(eval_php_os_family(), "Linux");
+ assert_eq!(eval_php_eol(), "\n");
+ assert_eq!(eval_directory_separator(), "/");
+ assert_eq!(eval_path_separator(), ":");
+ }
+ }
+
+ /// Verifies the POSIX separators never carry a Windows shape and vice versa, so a
+ /// future edit cannot make one constant disagree with the others.
+ #[test]
+ fn separators_are_internally_consistent() {
+ let sep = eval_directory_separator();
+ let path_sep = eval_path_separator();
+ let eol = eval_php_eol();
+ if sep == "\\" {
+ assert_eq!((path_sep, eol), (";", "\r\n"), "windows shape must be complete");
+ } else {
+ assert_eq!((sep, path_sep, eol), ("/", ":", "\n"), "posix shape must be complete");
+ }
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/constants.rs b/crates/elephc-magician/src/interpreter/constants.rs
index 281e2a56ff..884d277f0d 100644
--- a/crates/elephc-magician/src/interpreter/constants.rs
+++ b/crates/elephc-magician/src/interpreter/constants.rs
@@ -59,9 +59,15 @@ pub(super) const EVAL_STREAM_WRAPPERS: &[&str] = &[
];
/// Built-in stream transports reported by eval `stream_get_transports()`.
+#[cfg(not(windows))]
pub(super) const EVAL_STREAM_TRANSPORTS: &[&str] = &[
- "tcp", "udp", "unix", "udg", "tls", "ssl", "sslv2", "sslv3", "tlsv1.0", "tlsv1.1", "tlsv1.2",
- "tlsv1.3",
+ "tcp", "udp", "unix", "udg", "tls", "ssl", "tlsv1.0", "tlsv1.1", "tlsv1.2", "tlsv1.3",
+];
+
+/// Built-in stream transports reported by eval `stream_get_transports()` on Windows.
+#[cfg(windows)]
+pub(super) const EVAL_STREAM_TRANSPORTS: &[&str] = &[
+ "tcp", "udp", "tls", "ssl", "tlsv1.0", "tlsv1.1", "tlsv1.2", "tlsv1.3",
];
/// Monotonic salt mixed into eval `rand()`/`mt_rand()` and array key sampling.
diff --git a/crates/elephc-magician/src/interpreter/expressions/calls.rs b/crates/elephc-magician/src/interpreter/expressions/calls.rs
index a44858ef0b..df7f78a9fe 100644
--- a/crates/elephc-magician/src/interpreter/expressions/calls.rs
+++ b/crates/elephc-magician/src/interpreter/expressions/calls.rs
@@ -56,6 +56,9 @@ pub(in crate::interpreter) fn eval_call(
if name == "flock" {
return eval_builtin_flock(args, context, scope, values);
}
+ if name == "proc_open" {
+ return eval_builtin_proc_open_call(args, context, scope, values);
+ }
if name == "preg_match" {
return eval_builtin_preg_match_call(args, context, scope, values);
}
diff --git a/crates/elephc-magician/src/interpreter/libc_shims.rs b/crates/elephc-magician/src/interpreter/libc_shims.rs
index 1f6898483c..3aa9d5c261 100644
--- a/crates/elephc-magician/src/interpreter/libc_shims.rs
+++ b/crates/elephc-magician/src/interpreter/libc_shims.rs
@@ -1,45 +1,467 @@
//! Purpose:
-//! Declares libc routines used by eval builtins that mirror PHP process and network helpers.
+//! Provides portable OS shims used by eval builtins that mirror PHP network and system helpers.
//!
//! Called from:
//! - `crate::interpreter::builtins::network_env`
//! - `crate::interpreter::builtins::filesystem`
//!
//! Key details:
-//! - These bindings are unsafe FFI declarations only; call sites own pointer validation and
-//! PHP-compatible fallback behavior.
+//! - Unix delegates to libc while Windows initializes Winsock and uses Win32 system APIs.
+//! - Process-global resolver records are copied before returning to callers.
+use std::ffi::{CStr, c_char, c_int};
+#[cfg(unix)]
+use std::ffi::c_void;
+
+/// Owns the five fields exposed through PHP's `php_uname()` modes.
+pub(super) struct EvalUnameFields {
+ pub(super) sysname: Vec,
+ pub(super) nodename: Vec,
+ pub(super) release: Vec,
+ pub(super) version: Vec,
+ pub(super) machine: Vec,
+}
+
+/// Copies one NUL-terminated C character array into an owned byte vector.
+#[cfg(unix)]
+fn nul_terminated_bytes(field: &[c_char]) -> Vec {
+ let length = field
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(field.len());
+ field[..length].iter().map(|byte| *byte as u8).collect()
+}
+
+/// Returns the current hostname as raw PHP string bytes.
+#[cfg(unix)]
+pub(super) fn eval_os_hostname() -> Option> {
+ let mut buffer = [0 as c_char; 256];
+ let status = unsafe {
+ // libc writes at most buffer.len() bytes into this stack buffer.
+ libc::gethostname(buffer.as_mut_ptr(), buffer.len())
+ };
+ (status == 0).then(|| nul_terminated_bytes(&buffer))
+}
+
+/// Returns the current hostname as raw PHP string bytes.
+#[cfg(windows)]
+pub(super) fn eval_os_hostname() -> Option> {
+ windows::hostname()
+}
+
+/// Reverse-resolves an IPv4 address and copies the canonical host name.
+#[cfg(unix)]
+pub(super) fn eval_reverse_ipv4_name(octets: [u8; 4]) -> Option> {
+ let host = unsafe {
+ // libc reads the stack-owned IPv4 octets and returns process-global storage.
+ libc_gethostbyaddr(
+ octets.as_ptr().cast::(),
+ octets.len() as libc::socklen_t,
+ libc::AF_INET,
+ )
+ };
+ unsafe { copy_host_name(host) }
+}
+
+/// Reverse-resolves an IPv4 address and copies the canonical host name.
+#[cfg(windows)]
+pub(super) fn eval_reverse_ipv4_name(octets: [u8; 4]) -> Option> {
+ windows::reverse_ipv4_name(octets)
+}
+
+/// Looks up an IP protocol number by canonical name or alias.
+#[cfg(unix)]
+pub(super) fn eval_protocol_number(name: &CStr) -> Option {
+ let entry = unsafe { libc_getprotobyname(name.as_ptr()) };
+ (!entry.is_null()).then(|| unsafe { (*entry).p_proto })
+}
+
+/// Looks up an IP protocol number by canonical name or alias.
+#[cfg(windows)]
+pub(super) fn eval_protocol_number(name: &CStr) -> Option {
+ windows::protocol_number(name)
+}
+
+/// Looks up and copies an IP protocol's canonical name.
+#[cfg(unix)]
+pub(super) fn eval_protocol_name(number: i32) -> Option> {
+ let entry = unsafe { libc_getprotobynumber(number) };
+ unsafe { copy_c_name((!entry.is_null()).then(|| (*entry).p_name)) }
+}
+
+/// Looks up and copies an IP protocol's canonical name.
+#[cfg(windows)]
+pub(super) fn eval_protocol_name(number: i32) -> Option> {
+ windows::protocol_name(number)
+}
+
+/// Looks up an internet service port by service name and protocol.
+#[cfg(unix)]
+pub(super) fn eval_service_port(service: &CStr, protocol: &CStr) -> Option {
+ let entry = unsafe { libc_getservbyname(service.as_ptr(), protocol.as_ptr()) };
+ (!entry.is_null()).then(|| unsafe { u16::from_be((*entry).s_port as u16) })
+}
+
+/// Looks up an internet service port by service name and protocol.
+#[cfg(windows)]
+pub(super) fn eval_service_port(service: &CStr, protocol: &CStr) -> Option {
+ windows::service_port(service, protocol)
+}
+
+/// Looks up and copies an internet service's canonical name.
+#[cfg(unix)]
+pub(super) fn eval_service_name(port: u16, protocol: &CStr) -> Option> {
+ let entry = unsafe { libc_getservbyport(port.to_be() as c_int, protocol.as_ptr()) };
+ unsafe { copy_c_name((!entry.is_null()).then(|| (*entry).s_name)) }
+}
+
+/// Looks up and copies an internet service's canonical name.
+#[cfg(windows)]
+pub(super) fn eval_service_name(port: u16, protocol: &CStr) -> Option> {
+ windows::service_name(port, protocol)
+}
+
+/// Reads the operating-system identity fields used by `php_uname()`.
+#[cfg(unix)]
+pub(super) fn eval_os_uname() -> Option {
+ let mut utsname = std::mem::MaybeUninit::::zeroed();
+ let status = unsafe {
+ // libc initializes the entire stack-owned structure on success.
+ libc::uname(utsname.as_mut_ptr())
+ };
+ if status != 0 {
+ return None;
+ }
+ let utsname = unsafe { utsname.assume_init() };
+ Some(EvalUnameFields {
+ sysname: nul_terminated_bytes(&utsname.sysname),
+ nodename: nul_terminated_bytes(&utsname.nodename),
+ release: nul_terminated_bytes(&utsname.release),
+ version: nul_terminated_bytes(&utsname.version),
+ machine: nul_terminated_bytes(&utsname.machine),
+ })
+}
+
+/// Reads the operating-system identity fields used by `php_uname()`.
+#[cfg(windows)]
+pub(super) fn eval_os_uname() -> Option {
+ windows::uname()
+}
+
+/// Sets the process file-creation mask and returns the previous mask.
+#[cfg(unix)]
+pub(super) fn eval_os_umask(mask: u32) -> u32 {
+ unsafe { umask(mask) }
+}
+
+/// Sets the process file-creation mask and returns the previous mask.
+#[cfg(windows)]
+pub(super) fn eval_os_umask(mask: u32) -> u32 {
+ windows::umask(mask)
+}
+
+/// Copies a nullable C string pointer into owned storage.
+#[cfg(unix)]
+unsafe fn copy_c_name(name: Option<*mut c_char>) -> Option> {
+ let name = name?;
+ if name.is_null() {
+ return None;
+ }
+ Some(unsafe { CStr::from_ptr(name) }.to_bytes().to_vec())
+}
+
+/// Copies a libc host entry's canonical name into owned storage.
+#[cfg(unix)]
+unsafe fn copy_host_name(host: *mut libc::hostent) -> Option> {
+ if host.is_null() {
+ return None;
+ }
+ unsafe { copy_c_name(Some((*host).h_name)) }
+}
+
+#[cfg(unix)]
unsafe extern "C" {
/// Reverse-resolves one socket address through libc's `gethostbyaddr`.
#[link_name = "gethostbyaddr"]
- pub(super) fn libc_gethostbyaddr(
- addr: *const libc::c_void,
+ fn libc_gethostbyaddr(
+ addr: *const c_void,
len: libc::socklen_t,
- type_: libc::c_int,
+ type_: c_int,
) -> *mut libc::hostent;
/// Looks up one IP protocol entry by protocol name or alias.
#[link_name = "getprotobyname"]
- pub(super) fn libc_getprotobyname(name: *const libc::c_char) -> *mut libc::protoent;
+ fn libc_getprotobyname(name: *const c_char) -> *mut libc::protoent;
/// Looks up one IP protocol entry by protocol number.
#[link_name = "getprotobynumber"]
- pub(super) fn libc_getprotobynumber(proto: libc::c_int) -> *mut libc::protoent;
+ fn libc_getprotobynumber(proto: c_int) -> *mut libc::protoent;
/// Looks up one internet service entry by service name and protocol.
#[link_name = "getservbyname"]
- pub(super) fn libc_getservbyname(
- name: *const libc::c_char,
- proto: *const libc::c_char,
- ) -> *mut libc::servent;
+ fn libc_getservbyname(name: *const c_char, proto: *const c_char) -> *mut libc::servent;
/// Looks up one internet service entry by port and protocol.
#[link_name = "getservbyport"]
- pub(super) fn libc_getservbyport(
- port: libc::c_int,
- proto: *const libc::c_char,
- ) -> *mut libc::servent;
+ fn libc_getservbyport(port: c_int, proto: *const c_char) -> *mut libc::servent;
/// Sets the process file-creation mask and returns the previous mask.
pub(super) fn umask(mask: u32) -> u32;
}
+
+#[cfg(windows)]
+mod windows {
+ use super::*;
+ use std::mem::MaybeUninit;
+ use std::sync::OnceLock;
+
+ const AF_INET: c_int = 2;
+ const WINSOCK_VERSION_2_2: u16 = 0x0202;
+
+ #[repr(C)]
+ struct WsaData {
+ version: u16,
+ high_version: u16,
+ description: [u8; 257],
+ system_status: [u8; 129],
+ max_sockets: u16,
+ max_udp_datagram: u16,
+ vendor_info: *mut c_char,
+ }
+
+ #[repr(C)]
+ struct HostEnt {
+ name: *mut c_char,
+ aliases: *mut *mut c_char,
+ address_type: i16,
+ address_length: i16,
+ address_list: *mut *mut c_char,
+ }
+
+ #[repr(C)]
+ struct ProtoEnt {
+ name: *mut c_char,
+ aliases: *mut *mut c_char,
+ protocol: i16,
+ }
+
+ #[repr(C)]
+ struct OsVersionInfo {
+ size: u32,
+ major: u32,
+ minor: u32,
+ build: u32,
+ platform_id: u32,
+ service_pack: [u16; 128],
+ service_pack_major: u16,
+ service_pack_minor: u16,
+ suite_mask: u16,
+ product_type: u8,
+ reserved: u8,
+ }
+
+ static WINSOCK_READY: OnceLock = OnceLock::new();
+
+ /// Initializes Winsock 2.2 once for the process before database lookups.
+ fn ensure_winsock() -> bool {
+ *WINSOCK_READY.get_or_init(|| {
+ let mut data = MaybeUninit::::zeroed();
+ unsafe { WSAStartup(WINSOCK_VERSION_2_2, data.as_mut_ptr()) == 0 }
+ })
+ }
+
+ /// Copies a nullable Winsock-owned C string into owned storage.
+ unsafe fn copy_name(name: *mut c_char) -> Option> {
+ if name.is_null() {
+ return None;
+ }
+ Some(unsafe { CStr::from_ptr(name) }.to_bytes().to_vec())
+ }
+
+ /// Returns the current host name through Winsock.
+ pub(super) fn hostname() -> Option> {
+ if !ensure_winsock() {
+ return None;
+ }
+ let mut buffer = [0 as c_char; 256];
+ if unsafe { gethostname(buffer.as_mut_ptr(), buffer.len() as c_int) } != 0 {
+ return None;
+ }
+ unsafe { copy_name(buffer.as_mut_ptr()) }
+ }
+
+ /// Reverse-resolves one IPv4 address through Winsock.
+ pub(super) fn reverse_ipv4_name(octets: [u8; 4]) -> Option> {
+ if !ensure_winsock() {
+ return None;
+ }
+ let host = unsafe {
+ gethostbyaddr(
+ octets.as_ptr().cast::(),
+ octets.len() as c_int,
+ AF_INET,
+ )
+ };
+ if host.is_null() {
+ return None;
+ }
+ unsafe { copy_name((*host).name) }
+ }
+
+ /// Looks up a protocol number through the Winsock protocol database.
+ pub(super) fn protocol_number(name: &CStr) -> Option {
+ if !ensure_winsock() {
+ return None;
+ }
+ let entry = unsafe { getprotobyname(name.as_ptr()) };
+ (!entry.is_null()).then(|| unsafe { i32::from((*entry).protocol) })
+ }
+
+ /// Looks up a protocol name through the Winsock protocol database.
+ pub(super) fn protocol_name(number: i32) -> Option> {
+ if !ensure_winsock() {
+ return None;
+ }
+ let entry = unsafe { getprotobynumber(number) };
+ if entry.is_null() {
+ return None;
+ }
+ unsafe { copy_name((*entry).name) }
+ }
+
+ /// Looks up a service port through the Winsock services database.
+ pub(super) fn service_port(service: &CStr, protocol: &CStr) -> Option {
+ let service = service.to_string_lossy();
+ let protocol = protocol.to_string_lossy();
+ services_records().find_map(|record| {
+ (record.protocol.eq_ignore_ascii_case(&protocol)
+ && record.names.iter().any(|name| name.eq_ignore_ascii_case(&service)))
+ .then_some(record.port)
+ })
+ }
+
+ /// Looks up a service name through the Winsock services database.
+ pub(super) fn service_name(port: u16, protocol: &CStr) -> Option> {
+ let protocol = protocol.to_string_lossy();
+ services_records()
+ .find(|record| record.port == port && record.protocol.eq_ignore_ascii_case(&protocol))
+ .and_then(|record| record.names.into_iter().next())
+ .map(String::into_bytes)
+ }
+
+ /// Owns one parsed line from Windows' system services database.
+ struct ServiceRecord {
+ names: Vec,
+ port: u16,
+ protocol: String,
+ }
+
+ /// Reads and parses Windows' canonical services database.
+ fn services_records() -> impl Iterator {
+ let windows_root = std::env::var_os("SystemRoot")
+ .or_else(|| std::env::var_os("WINDIR"))
+ .unwrap_or_else(|| r"C:\Windows".into());
+ let path = std::path::PathBuf::from(windows_root)
+ .join("System32")
+ .join("drivers")
+ .join("etc")
+ .join("services");
+ std::fs::read_to_string(path)
+ .unwrap_or_default()
+ .lines()
+ .filter_map(parse_service_record)
+ .collect::>()
+ .into_iter()
+ }
+
+ /// Parses one services file line, preserving its canonical name and aliases.
+ fn parse_service_record(line: &str) -> Option {
+ let line = line.split('#').next()?.trim();
+ if line.is_empty() {
+ return None;
+ }
+ let mut fields = line.split_whitespace();
+ let canonical_name = fields.next()?;
+ let (port, protocol) = fields.next()?.split_once('/')?;
+ let port = port.parse::().ok()?;
+ let mut names = vec![canonical_name.to_string()];
+ names.extend(fields.map(str::to_string));
+ Some(ServiceRecord {
+ names,
+ port,
+ protocol: protocol.to_string(),
+ })
+ }
+
+ /// Reads Windows version data with the manifest-independent `RtlGetVersion` API.
+ pub(super) fn uname() -> Option {
+ let mut version = OsVersionInfo {
+ size: std::mem::size_of::() as u32,
+ major: 0,
+ minor: 0,
+ build: 0,
+ platform_id: 0,
+ service_pack: [0; 128],
+ service_pack_major: 0,
+ service_pack_minor: 0,
+ suite_mask: 0,
+ product_type: 0,
+ reserved: 0,
+ };
+ if unsafe { RtlGetVersion(&mut version) } < 0 {
+ return None;
+ }
+ let nodename = hostname().unwrap_or_default();
+ let release = format!("{}.{}", version.major, version.minor).into_bytes();
+ let service_pack_length = version
+ .service_pack
+ .iter()
+ .position(|unit| *unit == 0)
+ .unwrap_or(version.service_pack.len());
+ let service_pack = String::from_utf16_lossy(&version.service_pack[..service_pack_length]);
+ let version_text = if service_pack.is_empty() {
+ format!("build {}", version.build)
+ } else {
+ format!("build {} ({service_pack})", version.build)
+ };
+ Some(EvalUnameFields {
+ sysname: b"Windows NT".to_vec(),
+ nodename,
+ release,
+ version: version_text.into_bytes(),
+ machine: std::env::consts::ARCH.as_bytes().to_vec(),
+ })
+ }
+
+ /// Sets the process file-creation mask through the Microsoft C runtime.
+ pub(super) fn umask(mask: u32) -> u32 {
+ unsafe { c_umask(mask as c_int) as u32 }
+ }
+
+ #[link(name = "ws2_32")]
+ unsafe extern "system" {
+ /// Initializes the requested Winsock API version for this process.
+ fn WSAStartup(version: u16, data: *mut WsaData) -> c_int;
+ /// Writes the local hostname into the caller-owned buffer.
+ fn gethostname(name: *mut c_char, length: c_int) -> c_int;
+ /// Reverse-resolves one network address through Winsock's resolver database.
+ fn gethostbyaddr(address: *const c_char, length: c_int, kind: c_int) -> *mut HostEnt;
+ /// Looks up one protocol database entry by name or alias.
+ fn getprotobyname(name: *const c_char) -> *mut ProtoEnt;
+ /// Looks up one protocol database entry by numeric identifier.
+ fn getprotobynumber(number: c_int) -> *mut ProtoEnt;
+ }
+
+ #[link(name = "ntdll")]
+ unsafe extern "system" {
+ /// Reads the real Windows version independently of application manifests.
+ fn RtlGetVersion(version: *mut OsVersionInfo) -> i32;
+ }
+
+ #[link(name = "msvcrt")]
+ unsafe extern "C" {
+ /// Sets the Microsoft C runtime file-creation mask.
+ #[link_name = "_umask"]
+ fn c_umask(mask: c_int) -> c_int;
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/mod.rs b/crates/elephc-magician/src/interpreter/mod.rs
index 61ad04af78..79feec54f7 100644
--- a/crates/elephc-magician/src/interpreter/mod.rs
+++ b/crates/elephc-magician/src/interpreter/mod.rs
@@ -80,6 +80,7 @@ use throwables::*;
use std::ffi::{CStr, CString};
use std::mem::MaybeUninit;
use std::net::ToSocketAddrs;
+#[cfg(unix)]
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::sync::atomic::Ordering;
use std::time::{SystemTime, UNIX_EPOCH};
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_arrays_sets.rs b/crates/elephc-magician/src/interpreter/tests/builtins_arrays_sets.rs
index 0686df941f..080b21b2e7 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_arrays_sets.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_arrays_sets.rs
@@ -275,9 +275,16 @@ $secureCall = call_user_func("random_int", 5, 5);
echo ($secureCall === 5 ? "random-call" : "bad") . ":";
$secureSpread = call_user_func_array("random_int", ["min" => 6, "max" => 6]);
echo ($secureSpread === 6 ? "random-spread" : "bad") . ":";
+$bytes = random_bytes(length: 16);
+echo (strlen($bytes) === 16 ? "bytes" : "bad") . ":";
+$bytesCall = call_user_func("random_bytes", 8);
+echo (strlen($bytesCall) === 8 ? "bytes-call" : "bad") . ":";
+$bytesSpread = call_user_func_array("random_bytes", ["length" => 4]);
+echo (strlen($bytesSpread) === 4 ? "bytes-spread" : "bad") . ":";
echo function_exists("rand");
echo function_exists("mt_rand");
-return function_exists("random_int");"#,
+echo function_exists("random_int");
+return function_exists("random_bytes");"#,
)
.expect("parse eval fragment");
let mut scope = ElephcEvalScope::new();
@@ -287,7 +294,8 @@ return function_exists("random_int");"#,
assert_eq!(
values.output,
- "plain:range:same:swap:call:spread:random:random-call:random-spread:11"
+ "plain:range:same:swap:call:spread:random:random-call:random-spread:\
+bytes:bytes-call:bytes-spread:111"
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_metadata.rs b/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_metadata.rs
index 2c18e4bcf8..fbd2f1b6ad 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_metadata.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_metadata.rs
@@ -218,7 +218,7 @@ return true;"#
let _ = std::fs::remove_file(&filename);
let _ = std::fs::remove_file(&link);
std::fs::write(&filename, b"hello").expect("write stat fixture");
- std::os::unix::fs::symlink(&filename, &link).expect("create stat symlink");
+ create_file_symlink(&filename, &link).expect("create stat symlink");
let mut scope = ElephcEvalScope::new();
let mut values = FakeOps::default();
@@ -258,7 +258,7 @@ return true;"#
let _ = std::fs::remove_file(&filename);
let _ = std::fs::remove_file(&link);
std::fs::write(&filename, b"hello").expect("write stat array fixture");
- std::os::unix::fs::symlink(&filename, &link).expect("create stat array symlink");
+ create_file_symlink(&filename, &link).expect("create stat array symlink");
let mut scope = ElephcEvalScope::new();
let mut values = FakeOps::default();
@@ -272,3 +272,15 @@ return true;"#
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
+
+/// Creates a file symlink with the host platform's standard-library API.
+fn create_file_symlink(original: &str, link: &str) -> std::io::Result<()> {
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink(original, link)
+ }
+ #[cfg(windows)]
+ {
+ std::os::windows::fs::symlink_file(original, link)
+ }
+}
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_ops.rs b/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_ops.rs
index fc25eb689d..7b7e840f87 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_ops.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_filesystem_ops.rs
@@ -220,10 +220,14 @@ fn execute_program_dispatches_file_modify_builtins() {
let missing = format!("elephc_magician_modify_missing_{pid}.txt");
let prefix = format!("evm{pid}_");
let call_prefix = format!("evc{pid}_");
+ #[cfg(unix)]
+ let mode_check = format!("(fileperms(\"{filename}\") & 511) === 384");
+ #[cfg(windows)]
+ let mode_check = format!("(fileperms(\"{filename}\") & 128) !== 0");
let source = format!(
r#"file_put_contents("{filename}", "x");
echo chmod(filename: "{filename}", permissions: 384) ? "chmod" : "bad"; echo ":";
-echo (fileperms("{filename}") & 511) === 384 ? "mode" : "bad"; echo ":";
+echo {mode_check} ? "mode" : "bad"; echo ":";
echo chmod("{missing}", 384) ? "bad" : "chmod-false"; echo ":";
$tmp = tempnam(directory: ".", prefix: "{prefix}");
echo file_exists($tmp) && str_starts_with(basename($tmp), "{prefix}") ? "tempnam" : "bad"; echo ":";
@@ -273,8 +277,61 @@ return true;"#
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
+
+/// Verifies Windows chmod metadata follows aliases and filesystem mutations without leaking.
+#[cfg(windows)]
+#[test]
+fn execute_program_tracks_windows_chmod_metadata_by_file_identity() {
+ let pid = std::process::id();
+ let source_file = format!("elephc_magician_ModeAlias_{pid}.txt");
+ let case_alias = source_file.to_lowercase();
+ let hard_link = format!("elephc_magician_mode_hard_{pid}.txt");
+ let renamed = format!("elephc_magician_mode_renamed_{pid}.txt");
+ let copied = format!("elephc_magician_mode_copied_{pid}.txt");
+ let plain = format!("elephc_magician_mode_plain_{pid}.txt");
+ let replaced = format!("elephc_magician_mode_replaced_{pid}.txt");
+ let source = format!(
+ r#"file_put_contents("{source_file}", "source");
+chmod("{source_file}", 384);
+echo (fileperms("./{case_alias}") & 511) === 384 ? "alias" : "bad"; echo ":";
+link("{source_file}", "{hard_link}");
+unlink("{source_file}");
+echo (fileperms("{hard_link}") & 511) === 384 ? "hard" : "bad"; echo ":";
+rename("{hard_link}", "{renamed}");
+echo (fileperms("{renamed}") & 511) === 384 ? "rename" : "bad"; echo ":";
+copy("{renamed}", "{copied}");
+echo (fileperms("{copied}") & 511) === 384 ? "copy" : "bad"; echo ":";
+chmod("{copied}", 420);
+unlink("{copied}");
+file_put_contents("{copied}", "replacement");
+echo (fileperms("{copied}") & 511) !== 420 ? "recreate" : "bad"; echo ":";
+file_put_contents("{plain}", "plain");
+file_put_contents("{replaced}", "old");
+chmod("{replaced}", 420);
+copy("{plain}", "{replaced}");
+echo (fileperms("{replaced}") & 511) !== 420 ? "replace" : "bad"; echo ":";
+unlink("{renamed}"); unlink("{copied}"); unlink("{plain}"); unlink("{replaced}");
+return true;"#
+ );
+ let paths = [&source_file, &case_alias, &hard_link, &renamed, &copied, &plain, &replaced];
+ for path in paths {
+ let _ = std::fs::remove_file(path);
+ }
+ let program = parse_fragment(source.as_bytes()).expect("parse Windows chmod metadata fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ for path in paths {
+ let _ = std::fs::remove_file(path);
+ }
+ assert_eq!(values.output, "alias:hard:rename:copy:recreate:replace:");
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
/// Verifies eval ownership builtins mutate local files and dispatch dynamically.
#[test]
+#[cfg(unix)]
fn execute_program_dispatches_file_ownership_builtins() {
let pid = std::process::id();
let filename = format!("elephc_magician_ownership_{pid}.txt");
@@ -316,6 +373,46 @@ return function_exists("lchgrp");"#
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
+
+/// Verifies Windows local ownership builtins fail silently like PHP while remaining callable.
+#[test]
+#[cfg(windows)]
+fn execute_program_dispatches_windows_file_ownership_builtins() {
+ let pid = std::process::id();
+ let filename = format!("elephc_magician_ownership_{pid}.txt");
+ let missing = format!("elephc_magician_ownership_missing_{pid}.txt");
+ let source = format!(
+ r#"file_put_contents("{filename}", "x");
+echo chown("{filename}", 0) ? "bad" : "chown-false"; echo ":";
+echo chgrp(filename: "{filename}", group: 0) ? "bad" : "chgrp-false"; echo ":";
+echo chown("{filename}", "__elephc_eval_missing_user__") ? "bad" : "user-false"; echo ":";
+echo chgrp("{filename}", "__elephc_eval_missing_group__") ? "bad" : "group-false"; echo ":";
+echo chown("{missing}", 0) ? "bad" : "missing-false"; echo ":";
+echo call_user_func("chown", "{filename}", 0) ? "bad" : "call-false"; echo ":";
+echo call_user_func_array("chgrp", ["filename" => "{filename}", "group" => 0]) ? "bad" : "array-false"; echo ":";
+echo unlink("{filename}") ? "cleanup" : "bad"; echo ":";
+echo function_exists("chown"); echo function_exists("chgrp");
+echo function_exists("lchown"); echo function_exists("lchgrp");
+return true;"#
+ );
+ let _ = std::fs::remove_file(&filename);
+ let _ = std::fs::remove_file(&missing);
+ let program = parse_fragment(source.as_bytes()).expect("parse Windows eval ownership fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values)
+ .expect("execute Windows eval ownership fragment");
+
+ let _ = std::fs::remove_file(&filename);
+ let _ = std::fs::remove_file(&missing);
+ assert_eq!(
+ values.output,
+ "chown-false:chgrp-false:user-false:group-false:missing-false:call-false:array-false:cleanup:1100"
+ );
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
+
/// Verifies eval `touch()` creates files, stamps mtimes, and dispatches dynamically.
#[test]
fn execute_program_dispatches_touch_builtin() {
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_process_pipes.rs b/crates/elephc-magician/src/interpreter/tests/builtins_process_pipes.rs
index e517fad9aa..35485236d9 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_process_pipes.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_process_pipes.rs
@@ -13,6 +13,7 @@ use super::support::*;
/// Verifies `popen()` and `pclose()` support read/write pipes and dynamic calls.
#[test]
+#[cfg(unix)]
fn execute_program_dispatches_process_pipe_builtins() {
let pid = std::process::id();
let file = format!("elephc_magician_popen_{pid}.txt");
@@ -46,3 +47,138 @@ return true;"#
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
+
+/// Verifies Windows `popen()` uses `cmd.exe` pipes without Unix descriptor conversion.
+#[test]
+#[cfg(windows)]
+fn execute_program_dispatches_windows_process_pipe_builtins() {
+ let program = parse_fragment(
+ br#"$h = popen("echo|set /p=eval-popen", "r");
+echo is_resource($h) ? "open" : "bad"; echo ":";
+echo fread($h, 64) === "eval-popen" ? "read" : "bad"; echo ":";
+echo pclose($h) === 0 ? "closed" : "bad"; echo ":";
+echo function_exists("popen"); echo function_exists("pclose");
+return true;"#,
+ )
+ .expect("parse Windows eval process pipe fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ assert_eq!(values.output, "open:read:closed:11");
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
+
+/// Verifies eval `proc_open` owns a real child process, writes the pipes output,
+/// and `proc_close` returns the child's exit status.
+#[test]
+#[cfg(unix)]
+fn execute_program_dispatches_proc_open_and_close() {
+ let root = std::env::temp_dir().join(format!(
+ "elephc_magician_proc_open_{}",
+ std::process::id()
+ ));
+ std::fs::create_dir_all(&root).expect("create proc_open cwd");
+ let root = std::fs::canonicalize(&root).expect("canonicalize proc_open cwd");
+ let output = root.join("redirected.txt");
+ let source = format!(
+ r#"$pipes = [];
+$process = proc_open('read line; printf "%s|%s|%s" "$MAGIC" "$PWD" "$line"; printf "stderr" >&2; exit 9', [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes, "{}", ["MAGIC" => "env"]);
+echo is_resource($process) ? "open" : "bad"; echo ":";
+echo fwrite($pipes[0], "hello\n"); echo ":";
+echo fclose($pipes[0]) ? "closed" : "bad"; echo ":";
+echo stream_get_contents($pipes[1]); echo ":";
+echo stream_get_contents($pipes[2]); echo ":";
+echo fclose($pipes[1]) ? "outclosed" : "bad"; echo ":";
+echo fclose($pipes[2]) ? "errclosed" : "bad"; echo ":";
+echo proc_close($process); echo ":";
+$redirected = [];
+$second = proc_open('printf "file-out"; printf "%s" "-err" >&2; exit 7', [1 => ["file", "{}", "w"], 2 => 1], $redirected);
+echo count($redirected); echo ":";
+echo proc_close($second); echo ":";
+echo file_get_contents("{}"); echo ":";
+$direct_pipes = [];
+$direct = proc_open('/usr/bin/printf bypass', [1 => ["pipe", "w"]], $direct_pipes, null, null, ["bypass_shell" => true]);
+echo stream_get_contents($direct_pipes[1]); echo ":";
+echo proc_close($direct); echo ":";
+echo function_exists("proc_open"); echo function_exists("proc_close");
+return true;"#,
+ root.to_string_lossy(),
+ output.to_string_lossy(),
+ output.to_string_lossy(),
+ );
+ let program = parse_fragment(source.as_bytes()).expect("parse eval proc_open fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ let _ = std::fs::remove_dir_all(&root);
+ assert_eq!(
+ values.output,
+ format!(
+ "open:6:closed:env|{}|hello:stderr:outclosed:errclosed:9:0:7:file-out-err:bypass:0:11",
+ root.to_string_lossy()
+ )
+ );
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
+
+/// Verifies eval process status is non-consuming and the new process builtins are registered.
+#[test]
+#[cfg(unix)]
+fn execute_program_dispatches_proc_status_and_terminate_builtins() {
+ let program = parse_fragment(
+ br#"$pipes = [];
+$process = proc_open('sleep 1', [1 => ["pipe", "w"]], $pipes);
+$status = proc_get_status($process);
+echo $status["running"] ? "running" : "stopped"; echo ":";
+echo $status["cached"] ? "cached" : "fresh"; echo ":";
+echo is_int($status["pid"]) ? "pid" : "bad"; echo ":";
+echo call_user_func("proc_get_status", $process)["command"] === "sleep 1" ? "command" : "bad"; echo ":";
+echo proc_close($process) === 0 ? "closed" : "bad"; echo ":";
+echo proc_terminate($process) ? "bad" : "notfound"; echo ":";
+return function_exists("proc_get_status") && function_exists("proc_terminate");"#,
+ )
+ .expect("parse eval proc status fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ assert_eq!(values.output, "running:fresh:pid:command:closed:notfound:");
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
+
+/// Verifies Windows eval `proc_open` materializes all three pipe directions and
+/// preserves the child exit code under `cmd.exe`.
+#[test]
+#[cfg(windows)]
+fn execute_program_dispatches_windows_proc_open_descriptors() {
+ let program = parse_fragment(
+ br#"$pipes = [];
+$process = proc_open("more >NUL&&2 ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]], $pipes, null, ["MAGIC" => "env"]);
+echo is_resource($process) ? "open" : "bad"; echo ":";
+echo fwrite($pipes[0], "hello\r\n"); echo ":";
+echo fclose($pipes[0]) ? "closed" : "bad"; echo ":";
+echo stream_get_contents($pipes[1]); echo ":";
+echo stream_get_contents($pipes[2]); echo ":";
+echo fclose($pipes[1]) ? "outclosed" : "bad"; echo ":";
+echo fclose($pipes[2]) ? "errclosed" : "bad"; echo ":";
+echo proc_close($process); echo ":";
+echo function_exists("proc_open"); echo function_exists("proc_close");
+return true;"#,
+ )
+ .expect("parse Windows eval proc_open fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ assert_eq!(
+ values.output,
+ "open:7:closed:env:stderr:outclosed:errclosed:9:11"
+ );
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_strings_binary.rs b/crates/elephc-magician/src/interpreter/tests/builtins_strings_binary.rs
index d425a64573..313cdb53fc 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_strings_binary.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_strings_binary.rs
@@ -250,6 +250,26 @@ return function_exists("addslashes") && function_exists("stripslashes");"#,
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
+
+/// Verifies eval shell escaping is registry-visible through direct and callable dispatch.
+#[test]
+#[cfg(not(windows))]
+fn execute_program_dispatches_posix_shell_escape_builtins() {
+ let program = parse_fragment(
+ br#"echo escapeshellarg("a'b"); echo ":";
+echo escapeshellcmd("a&b"); echo ":";
+echo call_user_func("escapeshellcmd", "x|y"); echo ":";
+return function_exists("escapeshellarg") && function_exists("escapeshellcmd");"#,
+ )
+ .expect("parse eval shell escaping fragment");
+ let mut scope = ElephcEvalScope::new();
+ let mut values = FakeOps::default();
+
+ let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+
+ assert_eq!(values.output, "'a'\\''b':a\\&b:x\\|y:");
+ assert_eq!(values.get(result), FakeValue::Bool(true));
+}
/// Verifies eval `base64_encode()` dispatches through direct, named, and callable paths.
#[test]
fn execute_program_dispatches_base64_encode_builtin() {
diff --git a/crates/elephc-magician/src/interpreter/tests/builtins_system_network.rs b/crates/elephc-magician/src/interpreter/tests/builtins_system_network.rs
index f4fd060253..d52686662a 100644
--- a/crates/elephc-magician/src/interpreter/tests/builtins_system_network.rs
+++ b/crates/elephc-magician/src/interpreter/tests/builtins_system_network.rs
@@ -31,10 +31,14 @@ return function_exists("sys_get_temp_dir");"#,
let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
+ // The temporary directory is whatever php would resolve here, not the "/tmp"
+ // literal the interpreter used to return: TMPDIR wins, so on macOS this is a
+ // per-user /var/folders//T path.
+ let temp_dir = super::super::builtins::filesystem::sys_get_temp_dir::eval_temp_dir();
assert_eq!(
values.output,
format!(
- "time:{}:/tmp:cwd:call-time:{}:call-cwd:/tmp:111",
+ "time:{}:{temp_dir}:cwd:call-time:{}:call-cwd:{temp_dir}:111",
eval_compiler_php_version(),
eval_compiler_php_version()
)
@@ -134,12 +138,20 @@ return function_exists("strtotime");"#,
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
-/// Verifies eval `microtime()` returns a plausible float timestamp by all call paths.
+/// Verifies eval `microtime()` selects its return type from `as_float` on every call
+/// path, and that a float timestamp is plausible.
+///
+/// Only a truthy argument yields a float. The default and explicit-false forms
+/// return php's `""` string, which begins with "0." and so
+/// compares below 1000000000 — verified against php 8.5:
+/// `microtime() > 1000000000` and `microtime(false) > 1000000000` are both false,
+/// while `microtime(true) > 1000000000` is true. This test previously asserted the
+/// opposite, encoding the always-float behaviour this dispatch used to have.
#[test]
fn execute_program_dispatches_microtime_builtin() {
let program = parse_fragment(
- br#"echo microtime() > 1000000000 ? "now" : "bad"; echo ":";
-echo microtime(as_float: false) > 1000000000 ? "named" : "bad"; echo ":";
+ br#"echo microtime() > 1000000000 ? "bad" : "str"; echo ":";
+echo microtime(as_float: false) > 1000000000 ? "bad" : "str"; echo ":";
echo call_user_func("microtime", true) > 1000000000 ? "call" : "bad"; echo ":";
echo call_user_func_array("microtime", ["as_float" => true]) > 1000000000 ? "array" : "bad";
echo ":";
@@ -151,7 +163,7 @@ return function_exists("microtime");"#,
let result = execute_program(&program, &mut scope, &mut values).expect("execute eval ir");
- assert_eq!(values.output, "now:named:call:array:");
+ assert_eq!(values.output, "str:str:call:array:");
assert_eq!(values.get(result), FakeValue::Bool(true));
}
/// Verifies eval `hrtime()`, `http_response_code()`, and `header()` dispatch paths.
@@ -215,12 +227,12 @@ fn execute_program_dispatches_stream_introspection_builtins() {
$transports = stream_get_transports();
$filters = stream_get_filters();
echo count($wrappers) . ":" . $wrappers[0] . ":" . $wrappers[5] . ":";
-echo count($transports) . ":" . $transports[0] . ":" . $transports[8] . ":";
+echo count($transports) . ":" . $transports[0] . ":" . $transports[6] . ":";
echo count($filters) . ":" . $filters[2] . ":";
$call_wrappers = call_user_func("stream_get_wrappers");
echo $call_wrappers[10] . ":";
$call_transports = call_user_func_array("stream_get_transports", []);
-echo $call_transports[11] . ":";
+echo $call_transports[9] . ":";
$call_filters = call_user_func_array("stream_get_filters", []);
echo $call_filters[13] . ":";
echo function_exists("stream_get_wrappers"); echo function_exists("stream_get_transports");
@@ -234,7 +246,7 @@ return function_exists("stream_get_filters");"#,
assert_eq!(
values.output,
- "11:file:https:12:tcp:tlsv1.0:14:string.rot13:glob:tlsv1.3:bzip2.decompress:11"
+ "11:file:https:10:tcp:tlsv1.0:14:string.rot13:glob:tlsv1.3:bzip2.decompress:11"
);
assert_eq!(values.get(result), FakeValue::Bool(true));
}
diff --git a/crates/elephc-magician/src/stream_resources.rs b/crates/elephc-magician/src/stream_resources.rs
index 6525dfaddd..b6679d08a8 100644
--- a/crates/elephc-magician/src/stream_resources.rs
+++ b/crates/elephc-magician/src/stream_resources.rs
@@ -16,11 +16,14 @@ use std::ffi::c_void;
use std::fs::{File, Metadata, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::net::{Shutdown, TcpListener, TcpStream};
-use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd};
+#[cfg(unix)]
+use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, RawFd};
#[cfg(unix)]
use std::os::unix::net::UnixStream;
+#[cfg(windows)]
+use std::os::windows::io::{AsRawHandle, FromRawHandle};
use std::path::PathBuf;
-use std::process::{Child, Command, Stdio};
+use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use crate::stream_wrappers;
use crate::value::RuntimeCellHandle;
@@ -44,6 +47,7 @@ pub(crate) struct EvalStreamResources {
directories: HashMap,
filter_resources: HashSet,
hash_contexts: HashMap,
+ process_commands: HashMap,
process_children: HashMap,
socket_listeners: HashMap,
socket_names: HashMap,
@@ -54,3 +58,33 @@ pub(crate) struct EvalStreamResources {
user_wrapper_directories: HashMap,
user_wrapper_streams: HashMap,
}
+
+/// One child descriptor requested by eval `proc_open()`.
+#[derive(Clone, Debug)]
+pub(crate) enum EvalProcDescriptor {
+ /// Opens an anonymous pipe; `child_reads` follows PHP's child-side mode.
+ Pipe { child_reads: bool },
+ /// Opens a filesystem path for the child using a PHP fopen mode.
+ File { path: String, mode: String },
+ /// Duplicates another configured child descriptor.
+ Redirect(usize),
+}
+
+/// Process id and PHP descriptor-to-parent-pipe mapping returned by eval `proc_open()`.
+pub(crate) struct EvalProcOpenResult {
+ pub(crate) process_id: i64,
+ pub(crate) pipes: Vec<(i64, i64)>,
+}
+
+/// PHP-visible snapshot returned for an eval process resource.
+pub(crate) struct EvalProcessStatus {
+ pub(crate) cached: bool,
+ pub(crate) command: String,
+ pub(crate) exitcode: i64,
+ pub(crate) pid: i64,
+ pub(crate) running: bool,
+ pub(crate) signaled: bool,
+ pub(crate) stopped: bool,
+ pub(crate) stopsig: i64,
+ pub(crate) termsig: i64,
+}
diff --git a/crates/elephc-magician/src/stream_resources/file_process_opening.rs b/crates/elephc-magician/src/stream_resources/file_process_opening.rs
index 7c9d8fa5b1..90131fb845 100644
--- a/crates/elephc-magician/src/stream_resources/file_process_opening.rs
+++ b/crates/elephc-magician/src/stream_resources/file_process_opening.rs
@@ -56,8 +56,19 @@ impl EvalStreamResources {
'w' => false,
_ => return None,
};
- let mut child = Command::new("/bin/sh")
- .arg("-c")
+ #[cfg(unix)]
+ let mut shell = {
+ let mut shell = Command::new("/bin/sh");
+ shell.arg("-c");
+ shell
+ };
+ #[cfg(windows)]
+ let mut shell = {
+ let mut shell = Command::new("cmd.exe");
+ shell.arg("/C");
+ shell
+ };
+ let mut child = shell
.arg(command)
.stdin(if read_mode {
Stdio::null()
@@ -71,28 +82,174 @@ impl EvalStreamResources {
})
.spawn()
.ok()?;
- let file = if read_mode {
- let stdout = child.stdout.take()?;
- unsafe {
- // The ChildStdout pipe is converted into the File that backs
- // this eval stream; no second owner keeps the fd alive.
- File::from_raw_fd(stdout.into_raw_fd())
- }
+ let stream = if read_mode {
+ EvalFileStream::new_child_stdout(
+ child.stdout.take()?,
+ command.to_string(),
+ "r".to_string(),
+ )
} else {
- let stdin = child.stdin.take()?;
- unsafe {
- // The ChildStdin pipe is converted into the File that backs
- // this eval stream; dropping it before wait sends EOF.
- File::from_raw_fd(stdin.into_raw_fd())
- }
+ EvalFileStream::new_child_stdin(
+ child.stdin.take()?,
+ command.to_string(),
+ "w".to_string(),
+ )
};
- let id = self.insert(EvalFileStream::new(
- file,
- command.to_string(),
- if read_mode { "r" } else { "w" }.to_string(),
- ));
+ let id = self.insert(stream);
self.process_children.insert(id, child);
Some(id)
}
+ /// Starts a command with materialized child descriptors and returns both the
+ /// process resource and every parent-side pipe resource.
+ pub(crate) fn open_process(
+ &mut self,
+ command: &str,
+ descriptors: &[Option; 3],
+ cwd: Option<&str>,
+ env: Option<&[(String, String)]>,
+ bypass_shell: bool,
+ ) -> Option {
+ let mut child_handles: [Option; 3] = std::array::from_fn(|_| None);
+ let mut parent_pipes = Vec::new();
+ for (descriptor, spec) in descriptors.iter().enumerate() {
+ let Some(spec) = spec else {
+ continue;
+ };
+ match spec {
+ EvalProcDescriptor::Pipe { child_reads } => {
+ let (read, write) = eval_anonymous_pipe().ok()?;
+ let (child, parent, mode) = if *child_reads {
+ (read, write, "w")
+ } else {
+ (write, read, "r")
+ };
+ child_handles[descriptor] = Some(child);
+ parent_pipes.push((descriptor as i64, parent, mode));
+ }
+ EvalProcDescriptor::File { path, mode } => {
+ let parsed = EvalOpenMode::parse(mode)?;
+ child_handles[descriptor] = Some(parsed.open(path).ok()?);
+ }
+ EvalProcDescriptor::Redirect(_) => {}
+ }
+ }
+ for _ in 0..descriptors.len() {
+ let mut changed = false;
+ for (descriptor, spec) in descriptors.iter().enumerate() {
+ let Some(EvalProcDescriptor::Redirect(target)) = spec else {
+ continue;
+ };
+ if child_handles[descriptor].is_none() {
+ if let Some(target_handle) = child_handles.get(*target)?.as_ref() {
+ child_handles[descriptor] = Some(target_handle.try_clone().ok()?);
+ changed = true;
+ }
+ }
+ }
+ if !changed {
+ break;
+ }
+ }
+ if descriptors
+ .iter()
+ .enumerate()
+ .any(|(index, spec)| spec.is_some() && child_handles[index].is_none())
+ {
+ return None;
+ }
+
+ let mut command_builder;
+ if bypass_shell {
+ let mut parts = command.split_whitespace();
+ command_builder = Command::new(parts.next()?);
+ command_builder.args(parts);
+ } else {
+ #[cfg(unix)]
+ {
+ let mut shell = Command::new("/bin/sh");
+ shell.arg("-c");
+ command_builder = shell;
+ }
+ #[cfg(windows)]
+ {
+ let mut shell = Command::new("cmd.exe");
+ shell.arg("/C");
+ command_builder = shell;
+ }
+ command_builder.arg(command);
+ }
+ command_builder
+ .stdin(child_handles[0].take().map_or_else(Stdio::null, Stdio::from))
+ .stdout(child_handles[1].take().map_or_else(Stdio::null, Stdio::from))
+ .stderr(child_handles[2].take().map_or_else(Stdio::null, Stdio::from));
+ if let Some(cwd) = cwd {
+ command_builder.current_dir(cwd);
+ }
+ if let Some(env) = env {
+ command_builder.env_clear().envs(env.iter().cloned());
+ }
+ let child = command_builder.spawn().ok()?;
+ let id = self.next_id;
+ self.next_id += 1;
+ self.process_children.insert(id, child);
+ self.process_commands.insert(id, command.to_string());
+ let mut pipes = Vec::with_capacity(parent_pipes.len());
+ for (descriptor, parent, mode) in parent_pipes {
+ let pipe_id = self.insert(EvalFileStream::new(
+ parent,
+ format!("proc://{command}/{descriptor}"),
+ mode.to_string(),
+ ));
+ pipes.push((descriptor, pipe_id));
+ }
+ Some(EvalProcOpenResult {
+ process_id: id,
+ pipes,
+ })
+ }
+
+}
+
+/// Creates a parent/child anonymous byte pipe as owned file handles.
+#[cfg(unix)]
+fn eval_anonymous_pipe() -> io::Result<(File, File)> {
+ let mut fds = [-1; 2];
+ if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) })
+}
+
+/// Creates a Windows anonymous pipe; Rust's process launcher duplicates the
+/// selected child end with the inheritance required for stdio.
+#[cfg(windows)]
+fn eval_anonymous_pipe() -> io::Result<(File, File)> {
+ #[repr(C)]
+ struct SecurityAttributes {
+ length: u32,
+ descriptor: *mut c_void,
+ inherit: i32,
+ }
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ /// Creates the anonymous kernel pipe used by the child process.
+ fn CreatePipe(
+ read: *mut *mut c_void,
+ write: *mut *mut c_void,
+ attributes: *mut SecurityAttributes,
+ size: u32,
+ ) -> i32;
+ }
+ let mut read = std::ptr::null_mut();
+ let mut write = std::ptr::null_mut();
+ let mut attributes = SecurityAttributes {
+ length: std::mem::size_of::() as u32,
+ descriptor: std::ptr::null_mut(),
+ inherit: 0,
+ };
+ if unsafe { CreatePipe(&mut read, &mut write, &mut attributes, 0) } == 0 {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(unsafe { (File::from_raw_handle(read), File::from_raw_handle(write)) })
}
diff --git a/crates/elephc-magician/src/stream_resources/operations.rs b/crates/elephc-magician/src/stream_resources/operations.rs
index e5700c7d41..6f3e43af22 100644
--- a/crates/elephc-magician/src/stream_resources/operations.rs
+++ b/crates/elephc-magician/src/stream_resources/operations.rs
@@ -27,6 +27,7 @@ impl EvalStreamResources {
|| self.socket_listeners.remove(&id).is_some();
self.socket_names.remove(&id);
if let Some(mut child) = self.process_children.remove(&id) {
+ self.process_commands.remove(&id);
let _ = child.wait();
}
closed && ok
@@ -56,11 +57,7 @@ impl EvalStreamResources {
2 => Shutdown::Both,
_ => return Some(false),
};
- let result = unsafe {
- // libc shutdown only observes the borrowed descriptor and mode.
- libc::shutdown(stream.file.as_raw_fd(), eval_shutdown_how(shutdown))
- };
- Some(result == 0)
+ Some(stream.file.shutdown(shutdown).is_ok())
}
/// Allocates an eval-local stream filter resource handle.
@@ -79,11 +76,68 @@ impl EvalStreamResources {
/// Closes a process pipe stream and returns the child exit status.
pub(crate) fn pclose(&mut self, id: i64) -> Option {
let mut child = self.process_children.remove(&id)?;
+ self.process_commands.remove(&id);
self.streams.remove(&id)?;
let status = child.wait().ok()?;
Some(status.code().unwrap_or(0) as i64)
}
+ /// Waits for a process resource created by `proc_open` and returns its exit code.
+ pub(crate) fn close_process(&mut self, id: i64) -> Option {
+ let mut child = self.process_children.remove(&id)?;
+ self.process_commands.remove(&id);
+ let status = child.wait().ok()?;
+ Some(status.code().unwrap_or(0) as i64)
+ }
+
+ /// Returns a PHP-shaped status snapshot without consuming an eval process resource.
+ pub(crate) fn process_status(&mut self, id: i64) -> Option {
+ let command = self.process_commands.get(&id)?.clone();
+ let child = self.process_children.get_mut(&id)?;
+ let pid = i64::from(child.id());
+ let status = child.try_wait().ok()?;
+ let running = status.is_none();
+ let exitcode = status.and_then(|status| status.code()).map_or(-1, i64::from);
+ #[cfg(unix)]
+ let termsig = status
+ .and_then(|status| std::os::unix::process::ExitStatusExt::signal(&status))
+ .map_or(0, i64::from);
+ #[cfg(windows)]
+ let termsig = 0;
+ #[cfg(unix)]
+ let cached = !running;
+ #[cfg(windows)]
+ let cached = false;
+ Some(EvalProcessStatus {
+ cached,
+ command,
+ exitcode,
+ pid,
+ running,
+ signaled: termsig != 0,
+ stopped: false,
+ stopsig: 0,
+ termsig,
+ })
+ }
+
+ /// Sends an eval process a requested Unix signal or terminates it through Windows.
+ pub(crate) fn terminate_process(&mut self, id: i64, signal: i64) -> Option {
+ let child = self.process_children.get_mut(&id)?;
+ #[cfg(unix)]
+ {
+ let signal = libc::c_int::try_from(signal).ok()?;
+ // `child.id()` identifies the child process owned by this table and libc only
+ // observes that pid while delivering the requested signal.
+ return Some(unsafe { libc::kill(child.id() as libc::pid_t, signal) == 0 });
+ }
+ #[cfg(windows)]
+ {
+ let _ = signal;
+ Some(child.kill().is_ok())
+ }
+ }
+
/// Removes a directory resource from the table.
pub(crate) fn close_directory(&mut self, id: i64) -> bool {
self.directories.remove(&id).is_some()
@@ -186,16 +240,25 @@ impl EvalStreamResources {
/// Returns whether a stream's file descriptor is attached to a terminal.
pub(crate) fn isatty(&self, id: i64) -> Option {
let stream = self.streams.get(&id)?;
+ #[cfg(unix)]
let result = unsafe {
// libc only reads the descriptor value during the terminal probe.
libc::isatty(stream.file.as_raw_fd())
};
- Some(result == 1)
+ #[cfg(unix)]
+ return Some(result == 1);
+ #[cfg(windows)]
+ {
+ let _ = stream;
+ Some(false)
+ }
}
/// Toggles blocking mode on a stream's file descriptor.
pub(crate) fn set_blocking(&self, id: i64, enable: bool) -> Option {
let stream = self.streams.get(&id)?;
+ #[cfg(unix)]
+ {
let fd = stream.file.as_raw_fd();
let flags = unsafe {
// fcntl reads the current descriptor flags without taking ownership.
@@ -214,6 +277,9 @@ impl EvalStreamResources {
libc::fcntl(fd, libc::F_SETFL, flags)
};
Some(result == 0)
+ }
+ #[cfg(windows)]
+ { Some(stream.file.set_nonblocking(!enable).is_ok()) }
}
/// Reports timeout-setting support for local file streams.
@@ -238,15 +304,24 @@ impl EvalStreamResources {
pub(crate) fn flock(&self, id: i64, operation: i64) -> Option<(bool, bool)> {
let stream = self.streams.get(&id)?;
let operation = eval_flock_operation(operation)?;
+ #[cfg(unix)]
let result = unsafe {
// libc only observes the borrowed raw fd during this call.
libc::flock(stream.file.as_raw_fd(), operation)
};
+ #[cfg(windows)]
+ let result = stream.file.flock(i64::from(operation));
+ #[cfg(unix)]
if result == 0 {
Some((true, false))
} else {
Some((false, eval_flock_would_block()))
}
+ #[cfg(windows)]
+ match result {
+ Ok(()) => Some((true, false)),
+ Err(error) => Some((false, matches!(error.raw_os_error(), Some(33 | 997)))),
+ }
}
/// Synchronizes stream data and metadata to storage.
diff --git a/crates/elephc-magician/src/stream_resources/sockets.rs b/crates/elephc-magician/src/stream_resources/sockets.rs
index f98928c356..57e882024a 100644
--- a/crates/elephc-magician/src/stream_resources/sockets.rs
+++ b/crates/elephc-magician/src/stream_resources/sockets.rs
@@ -14,6 +14,9 @@ impl EvalStreamResources {
/// Opens a TCP listener resource for `stream_socket_server()`.
pub(crate) fn open_tcp_listener(&mut self, address: &str) -> Option {
+ if eval_address_selects_tls(address) {
+ return None;
+ }
let listener = TcpListener::bind(eval_tcp_address(address)).ok()?;
let local = listener.local_addr().ok()?.to_string();
let id = self.next_id;
@@ -36,6 +39,9 @@ impl EvalStreamResources {
/// Opens a connected TCP stream resource and preserves the host I/O error on failure.
pub(crate) fn open_tcp_stream_result(&mut self, address: &str) -> io::Result {
+ if eval_address_selects_tls(address) {
+ return Err(eval_tls_transport_unsupported());
+ }
let stream = TcpStream::connect(eval_tcp_address(address))?;
self.insert_tcp_stream(stream).ok_or_else(|| {
io::Error::new(io::ErrorKind::Other, "failed to track eval TCP stream")
@@ -53,11 +59,10 @@ impl EvalStreamResources {
host: &str,
port: i64,
) -> io::Result {
- let host = host
- .strip_prefix("tcp://")
- .or_else(|| host.strip_prefix("ssl://"))
- .or_else(|| host.strip_prefix("tls://"))
- .unwrap_or(host);
+ if eval_address_selects_tls(host) {
+ return Err(eval_tls_transport_unsupported());
+ }
+ let host = host.strip_prefix("tcp://").unwrap_or(host);
self.open_tcp_stream_result(&format!("{host}:{port}"))
}
@@ -107,9 +112,15 @@ impl EvalStreamResources {
);
Some((left_id, right_id))
}
- #[cfg(not(unix))]
+ #[cfg(windows)]
{
- None
+ let listener = TcpListener::bind("127.0.0.1:0").ok()?;
+ let address = listener.local_addr().ok()?;
+ let left = TcpStream::connect(address).ok()?;
+ let (right, _) = listener.accept().ok()?;
+ let left_id = self.insert_tcp_stream(left)?;
+ let right_id = self.insert_tcp_stream(right)?;
+ Some((left_id, right_id))
}
}
diff --git a/crates/elephc-magician/src/stream_resources/storage.rs b/crates/elephc-magician/src/stream_resources/storage.rs
index 9d8794020d..9a100e931e 100644
--- a/crates/elephc-magician/src/stream_resources/storage.rs
+++ b/crates/elephc-magician/src/stream_resources/storage.rs
@@ -83,11 +83,11 @@ impl EvalStreamResources {
pub(super) fn insert_tcp_stream(&mut self, stream: TcpStream) -> Option {
let local = stream.local_addr().ok()?.to_string();
let peer = stream.peer_addr().ok().map(|addr| addr.to_string());
- let file = unsafe {
- // The TcpStream is moved into the File-backed eval stream.
- File::from_raw_fd(stream.into_raw_fd())
- };
- let id = self.insert(EvalFileStream::new(file, local.clone(), "r+".to_string()));
+ let id = self.insert(EvalFileStream::new_tcp(
+ stream,
+ local.clone(),
+ "r+".to_string(),
+ ));
self.socket_names
.insert(id, EvalSocketNames { local, peer });
Some(id)
diff --git a/crates/elephc-magician/src/stream_resources/types.rs b/crates/elephc-magician/src/stream_resources/types.rs
index 3b2d943117..c237f2d783 100644
--- a/crates/elephc-magician/src/stream_resources/types.rs
+++ b/crates/elephc-magician/src/stream_resources/types.rs
@@ -37,36 +37,75 @@ pub(super) struct EvalSocketNames {
}
/// Normalizes supported TCP-style stream socket addresses.
+///
+/// Only the plaintext `tcp://` scheme is stripped. The crypto transports used to be
+/// stripped here too, which silently turned `ssl://` and `tls://` into unencrypted
+/// TCP connections; `eval_address_selects_tls` now rejects them before they reach
+/// this point.
pub(super) fn eval_tcp_address(address: &str) -> &str {
- address
- .strip_prefix("tcp://")
- .or_else(|| address.strip_prefix("ssl://"))
- .or_else(|| address.strip_prefix("tls://"))
- .unwrap_or(address)
+ address.strip_prefix("tcp://").unwrap_or(address)
}
-/// Converts Rust's socket shutdown enum into libc constants.
-pub(super) fn eval_shutdown_how(shutdown: Shutdown) -> libc::c_int {
- match shutdown {
- Shutdown::Read => libc::SHUT_RD,
- Shutdown::Write => libc::SHUT_WR,
- Shutdown::Both => libc::SHUT_RDWR,
- }
+/// Reports whether an address names one of PHP's crypto transports.
+///
+/// php-src registers `ssl`, `sslv3`, `tls` and `tlsv1.0`..`tlsv1.3` as transports
+/// that negotiate TLS inside the connect (`php_openssl_ssl_socket_factory`,
+/// ext/openssl/xp_ssl.c). Scheme names are case-insensitive. `sslv2` is excluded
+/// because php rejects it rather than negotiating. This mirrors the scheme set the
+/// compiled path matches in `__rt_addr_tls_crypto_method`.
+pub(super) fn eval_address_selects_tls(address: &str) -> bool {
+ let Some((scheme, _)) = address.split_once("://") else {
+ return false;
+ };
+ matches!(
+ scheme.to_ascii_lowercase().as_str(),
+ "ssl" | "sslv3" | "tls" | "tlsv1.0" | "tlsv1.1" | "tlsv1.2" | "tlsv1.3"
+ )
+}
+
+/// Returns the error reported when an eval fragment asks for a crypto transport.
+///
+/// The interpreter has no TLS engine: `elephc-magician` does not link the
+/// `elephc-tls` bridge, so it cannot complete a handshake. Refusing is the only
+/// honest answer -- returning a plaintext socket for a `tls://` address would hand
+/// back an unencrypted connection under a name that promises confidentiality, which
+/// is strictly worse than failing. The compiled path negotiates these transports
+/// normally; only dynamic `eval()` fragments reach this fallback.
+pub(super) fn eval_tls_transport_unsupported() -> io::Error {
+ io::Error::new(
+ io::ErrorKind::Unsupported,
+ "TLS stream transports are not available inside eval(); \
+ the interpreter fallback has no TLS engine and will not open an \
+ unencrypted socket for a crypto scheme",
+ )
}
/// Converts PHP `LOCK_*` bit flags into host `flock()` flags.
pub(super) fn eval_flock_operation(operation: i64) -> Option {
let non_blocking = operation & 4 != 0;
+ #[cfg(unix)]
let base = match operation & !4 {
1 => libc::LOCK_SH,
2 => libc::LOCK_EX,
3 => libc::LOCK_UN,
_ => return None,
};
- Some(base | if non_blocking { libc::LOCK_NB } else { 0 })
+ #[cfg(windows)]
+ let base = match operation & !4 {
+ 1 => 1,
+ 2 => 2,
+ 3 => 3,
+ _ => return None,
+ };
+ #[cfg(unix)]
+ let non_blocking_flag = libc::LOCK_NB;
+ #[cfg(windows)]
+ let non_blocking_flag = 4;
+ Some(base | if non_blocking { non_blocking_flag } else { 0 })
}
/// Returns whether the last host `flock()` failure was a non-blocking lock miss.
+#[cfg(unix)]
pub(super) fn eval_flock_would_block() -> bool {
let errno = std::io::Error::last_os_error().raw_os_error();
errno.is_some_and(|code| code == libc::EWOULDBLOCK || code == libc::EAGAIN)
@@ -97,9 +136,214 @@ pub(super) fn eval_builtin_stream_wrapper_exists(builtins: &[&str], protocol: &s
.any(|builtin| builtin.eq_ignore_ascii_case(protocol))
}
+/// Host I/O object stored behind one eval stream resource.
+pub(super) enum EvalStreamHandle {
+ File(File),
+ Tcp(TcpStream),
+ ChildStdout(ChildStdout),
+ ChildStdin(ChildStdin),
+}
+
+impl Read for EvalStreamHandle {
+ /// Reads from handles that expose a readable byte stream.
+ fn read(&mut self, buffer: &mut [u8]) -> io::Result {
+ match self {
+ Self::File(handle) => handle.read(buffer),
+ Self::Tcp(handle) => handle.read(buffer),
+ Self::ChildStdout(handle) => handle.read(buffer),
+ Self::ChildStdin(_) => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "process stdin is not readable",
+ )),
+ }
+ }
+}
+
+impl Write for EvalStreamHandle {
+ /// Writes to handles that expose a writable byte stream.
+ fn write(&mut self, buffer: &[u8]) -> io::Result {
+ match self {
+ Self::File(handle) => handle.write(buffer),
+ Self::Tcp(handle) => handle.write(buffer),
+ Self::ChildStdin(handle) => handle.write(buffer),
+ Self::ChildStdout(_) => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "process stdout is not writable",
+ )),
+ }
+ }
+
+ /// Flushes writable handles while treating unbuffered sockets as already flushed.
+ fn flush(&mut self) -> io::Result<()> {
+ match self {
+ Self::File(handle) => handle.flush(),
+ Self::Tcp(handle) => handle.flush(),
+ Self::ChildStdin(handle) => handle.flush(),
+ Self::ChildStdout(_) => Ok(()),
+ }
+ }
+}
+
+impl Seek for EvalStreamHandle {
+ /// Seeks regular files and rejects non-seekable sockets and process pipes.
+ fn seek(&mut self, position: SeekFrom) -> io::Result {
+ match self {
+ Self::File(handle) => handle.seek(position),
+ Self::Tcp(_) | Self::ChildStdout(_) | Self::ChildStdin(_) => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "stream is not seekable",
+ )),
+ }
+ }
+}
+
+impl EvalStreamHandle {
+ /// Returns regular-file metadata when the resource has a file backing.
+ pub(super) fn metadata(&self) -> io::Result {
+ match self {
+ Self::File(handle) => handle.metadata(),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "no file metadata")),
+ }
+ }
+
+ /// Resizes a regular file and rejects non-file resources.
+ pub(super) fn set_len(&self, size: u64) -> io::Result<()> {
+ match self {
+ Self::File(handle) => handle.set_len(size),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "stream is not resizable")),
+ }
+ }
+
+ /// Synchronizes a regular file's data and metadata.
+ pub(super) fn sync_all(&self) -> io::Result<()> {
+ match self {
+ Self::File(handle) => handle.sync_all(),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "stream is not syncable")),
+ }
+ }
+
+ /// Synchronizes a regular file's data.
+ pub(super) fn sync_data(&self) -> io::Result<()> {
+ match self {
+ Self::File(handle) => handle.sync_data(),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "stream is not syncable")),
+ }
+ }
+
+ /// Applies socket shutdown only to TCP-backed resources.
+ pub(super) fn shutdown(&self, how: Shutdown) -> io::Result<()> {
+ match self {
+ Self::Tcp(handle) => handle.shutdown(how),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "stream is not a socket")),
+ }
+ }
+
+ /// Toggles non-blocking mode only for TCP-backed resources.
+ #[cfg(windows)]
+ pub(super) fn set_nonblocking(&self, nonblocking: bool) -> io::Result<()> {
+ match self {
+ Self::Tcp(handle) => handle.set_nonblocking(nonblocking),
+ _ => Err(io::Error::new(io::ErrorKind::Unsupported, "stream is not a socket")),
+ }
+ }
+
+ /// Applies PHP flock semantics to regular Windows file handles.
+ #[cfg(windows)]
+ pub(super) fn flock(&self, operation: i64) -> io::Result<()> {
+ use std::ffi::c_void;
+
+ #[repr(C)]
+ struct Overlapped {
+ internal: usize,
+ internal_high: usize,
+ offset: u32,
+ offset_high: u32,
+ event: *mut c_void,
+ }
+
+ #[link(name = "kernel32")]
+ unsafe extern "system" {
+ /// Acquires a shared or exclusive byte-range lock on a Windows file handle.
+ fn LockFileEx(
+ file: *mut c_void,
+ flags: u32,
+ reserved: u32,
+ bytes_low: u32,
+ bytes_high: u32,
+ overlapped: *mut Overlapped,
+ ) -> i32;
+ /// Releases a byte-range lock from a Windows file handle.
+ fn UnlockFileEx(
+ file: *mut c_void,
+ reserved: u32,
+ bytes_low: u32,
+ bytes_high: u32,
+ overlapped: *mut Overlapped,
+ ) -> i32;
+ }
+
+ const LOCKFILE_FAIL_IMMEDIATELY: u32 = 0x0000_0001;
+ const LOCKFILE_EXCLUSIVE_LOCK: u32 = 0x0000_0002;
+ let Self::File(file) = self else {
+ return Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "flock requires a regular file",
+ ));
+ };
+ let mut overlapped = Overlapped {
+ internal: 0,
+ internal_high: 0,
+ offset: 0,
+ offset_high: 0,
+ event: std::ptr::null_mut(),
+ };
+ let base = operation & !4;
+ let status = unsafe {
+ if base == 3 {
+ UnlockFileEx(
+ file.as_raw_handle(),
+ 0,
+ u32::MAX,
+ u32::MAX,
+ &mut overlapped,
+ )
+ } else {
+ let mut flags = if base == 2 { LOCKFILE_EXCLUSIVE_LOCK } else { 0 };
+ if operation & 4 != 0 {
+ flags |= LOCKFILE_FAIL_IMMEDIATELY;
+ }
+ LockFileEx(
+ file.as_raw_handle(),
+ flags,
+ 0,
+ u32::MAX,
+ u32::MAX,
+ &mut overlapped,
+ )
+ }
+ };
+ if status == 0 {
+ Err(io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+ }
+
+ /// Returns the borrowed Unix descriptor used by terminal and lock operations.
+ #[cfg(unix)]
+ pub(super) fn as_raw_fd(&self) -> RawFd {
+ match self {
+ Self::File(handle) => handle.as_raw_fd(),
+ Self::Tcp(handle) => handle.as_raw_fd(),
+ Self::ChildStdout(handle) => handle.as_raw_fd(),
+ Self::ChildStdin(handle) => handle.as_raw_fd(),
+ }
+ }
+}
+
/// File stream stored behind one eval resource id.
pub(super) struct EvalFileStream {
- pub(super) file: File,
+ pub(super) file: EvalStreamHandle,
pub(super) uri: String,
pub(super) mode: String,
pub(super) eof: bool,
@@ -120,7 +364,7 @@ impl EvalFileStream {
flush_target: Option,
) -> Self {
Self {
- file,
+ file: EvalStreamHandle::File(file),
uri,
mode,
eof: false,
@@ -128,6 +372,39 @@ impl EvalFileStream {
}
}
+ /// Creates a tracked stream around a TCP socket without conflating SOCKET and HANDLE on Windows.
+ pub(super) fn new_tcp(stream: TcpStream, uri: String, mode: String) -> Self {
+ Self {
+ file: EvalStreamHandle::Tcp(stream),
+ uri,
+ mode,
+ eof: false,
+ flush_target: None,
+ }
+ }
+
+ /// Creates a readable tracked stream around a child process stdout pipe.
+ pub(super) fn new_child_stdout(stream: ChildStdout, uri: String, mode: String) -> Self {
+ Self {
+ file: EvalStreamHandle::ChildStdout(stream),
+ uri,
+ mode,
+ eof: false,
+ flush_target: None,
+ }
+ }
+
+ /// Creates a writable tracked stream around a child process stdin pipe.
+ pub(super) fn new_child_stdin(stream: ChildStdin, uri: String, mode: String) -> Self {
+ Self {
+ file: EvalStreamHandle::ChildStdin(stream),
+ uri,
+ mode,
+ eof: false,
+ flush_target: None,
+ }
+ }
+
/// Flushes any buffered wrapper target before the stream resource disappears.
pub(super) fn finalize_on_close(mut self) -> bool {
let Some(flush_target) = self.flush_target.take() else {
@@ -358,3 +635,53 @@ pub(super) fn eval_tmpfile_nonce() -> u128 {
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
+
+#[cfg(test)]
+mod transport_scheme_tests {
+ use super::{eval_address_selects_tls, eval_tcp_address};
+
+ /// Verifies every crypto transport php-src registers is recognised, in any case,
+ /// so none of them can fall through to an unencrypted socket.
+ #[test]
+ fn detects_every_crypto_scheme() {
+ for address in [
+ "ssl://h:1",
+ "sslv3://h:1",
+ "tls://h:1",
+ "tlsv1.0://h:1",
+ "tlsv1.1://h:1",
+ "tlsv1.2://h:1",
+ "tlsv1.3://h:1",
+ "TLS://h:1",
+ "SsL://h:1",
+ ] {
+ assert!(eval_address_selects_tls(address), "{address} must select TLS");
+ }
+ }
+
+ /// Verifies plaintext and unknown schemes stay on the plain path. `sslv2` belongs
+ /// here because php rejects it outright rather than negotiating it.
+ #[test]
+ fn leaves_plain_schemes_alone() {
+ for address in [
+ "tcp://h:1",
+ "udp://h:1",
+ "unix:///tmp/s",
+ "sslv2://h:1",
+ "tlsv1.4://h:1",
+ "tlsx://h:1",
+ "h:1",
+ ] {
+ assert!(!eval_address_selects_tls(address), "{address} must stay plain");
+ }
+ }
+
+ /// Verifies only the plaintext scheme is stripped, so a crypto address can never
+ /// be normalised into something `TcpStream::connect` would happily open.
+ #[test]
+ fn strips_only_the_plaintext_scheme() {
+ assert_eq!(eval_tcp_address("tcp://h:1"), "h:1");
+ assert_eq!(eval_tcp_address("h:1"), "h:1");
+ assert_eq!(eval_tcp_address("tls://h:1"), "tls://h:1");
+ }
+}
diff --git a/crates/elephc-pdo/src/lib.rs b/crates/elephc-pdo/src/lib.rs
index 3145d2fd08..d6ec2a14da 100644
--- a/crates/elephc-pdo/src/lib.rs
+++ b/crates/elephc-pdo/src/lib.rs
@@ -123,7 +123,7 @@ fn drivername_cell() -> &'static Mutex {
fn store_cstr(cell: &'static Mutex, s: &str) -> *const c_char {
let bytes: Vec = s.bytes().filter(|&b| b != 0).collect();
let cstr = CString::new(bytes).unwrap_or_default();
- let mut guard = cell.lock().unwrap();
+ let mut guard = cell.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*guard = cstr;
guard.as_ptr()
}
@@ -132,7 +132,7 @@ fn store_cstr(cell: &'static Mutex, s: &str) -> *const c_char {
/// to the first byte, or null for an empty buffer. Valid until the next column
/// data pointer call; elephc copies it immediately through `ptr_read_string`.
fn store_bytes(bytes: Vec) -> *const c_char {
- let mut guard = coldata_cell().lock().unwrap();
+ let mut guard = coldata_cell().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*guard = bytes;
if guard.is_empty() {
std::ptr::null()
@@ -173,7 +173,7 @@ fn open_conn_for_dsn(dsn: &str) -> Result {
/// Registers a newly opened connection and returns the public handle ID.
fn register_conn(conn: Conn) -> i64 {
let id = next_id();
- conns().lock().unwrap().insert(id, conn);
+ conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(id, conn);
id
}
@@ -191,8 +191,8 @@ fn open_nonpersistent_dsn(dsn: &str) -> i64 {
/// Opens or reuses a process-local persistent connection for the full DSN.
fn open_persistent_dsn(dsn: &str) -> i64 {
- if let Some(id) = persistent_conns().lock().unwrap().get(dsn).copied() {
- if conns().lock().unwrap().contains_key(&id) {
+ if let Some(id) = persistent_conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(dsn).copied() {
+ if conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).contains_key(&id) {
return id;
}
}
@@ -201,9 +201,9 @@ fn open_persistent_dsn(dsn: &str) -> i64 {
let id = register_conn(conn);
persistent_conns()
.lock()
- .unwrap()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(dsn.to_string(), id);
- persistent_ids().lock().unwrap().insert(id);
+ persistent_ids().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(id);
id
}
Err(msg) => {
@@ -225,7 +225,7 @@ pub extern "C" fn elephc_pdo_version() -> i32 {
/// `elephc_pdo_driver_name`.
#[no_mangle]
pub extern "C" fn elephc_pdo_driver_name(conn_id: i64) -> *const c_char {
- let name = match conns().lock().unwrap().get(&conn_id) {
+ let name = match conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn_id) {
Some(Conn::Sqlite(_)) => "sqlite",
Some(Conn::Postgres(_)) => "pgsql",
Some(Conn::Mysql(_)) => "mysql",
@@ -275,20 +275,20 @@ pub unsafe extern "C" fn elephc_pdo_open_persistent(
/// `elephc_pdo_open`. Valid until the next failed open.
#[no_mangle]
pub extern "C" fn elephc_pdo_last_open_error() -> *const c_char {
- open_error_cell().lock().unwrap().as_ptr()
+ open_error_cell().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).as_ptr()
}
/// Closes a connection (finalizing any SQLite statements still registered against
/// it) and removes it from the table. Unknown handles are ignored.
#[no_mangle]
pub extern "C" fn elephc_pdo_close(conn_id: i64) {
- if persistent_ids().lock().unwrap().contains(&conn_id) {
+ if persistent_ids().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).contains(&conn_id) {
return;
}
// The SQLite db pointer of the connection being closed, so only *its*
// statements are finalized (statements from other open SQLite connections
// must be left alone). `None` when the connection is PostgreSQL or unknown.
- let sqlite_db = match conns().lock().unwrap().get(&conn_id) {
+ let sqlite_db = match conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn_id) {
Some(Conn::Sqlite(c)) => Some(c.db),
_ => None,
};
@@ -297,7 +297,7 @@ pub extern "C" fn elephc_pdo_close(conn_id: i64) {
// live server-side and are dropped with the client.
let owned: Vec = stmts()
.lock()
- .unwrap()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
.iter()
.filter_map(|(k, s)| match s {
Stmt::Sqlite(st) if sqlite_db == Some(st.db) => Some(*k),
@@ -307,7 +307,7 @@ pub extern "C" fn elephc_pdo_close(conn_id: i64) {
})
.collect();
{
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
for k in owned {
if let Some(Stmt::Sqlite(s)) = guard.get(&k) {
s.finalize();
@@ -315,10 +315,10 @@ pub extern "C" fn elephc_pdo_close(conn_id: i64) {
guard.remove(&k);
}
}
- if let Some(Conn::Sqlite(c)) = conns().lock().unwrap().get(&conn_id) {
+ if let Some(Conn::Sqlite(c)) = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn_id) {
c.close();
}
- conns().lock().unwrap().remove(&conn_id);
+ conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&conn_id);
}
/// Runs one or more SQL statements with no result rows (`PDO::exec`). Returns the
@@ -328,7 +328,7 @@ pub extern "C" fn elephc_pdo_close(conn_id: i64) {
/// `sql` must point to a NUL-terminated string valid for the duration of the call.
#[no_mangle]
pub unsafe extern "C" fn elephc_pdo_exec(conn_id: i64, sql: *const c_char) -> i64 {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.exec(sql),
Some(Conn::Postgres(c)) => match cstr_arg(sql) {
@@ -350,7 +350,7 @@ pub unsafe extern "C" fn elephc_pdo_exec(conn_id: i64, sql: *const c_char) -> i6
/// `name`, when non-null, must point to a NUL-terminated string valid for the call.
#[no_mangle]
pub unsafe extern "C" fn elephc_pdo_last_insert_id(conn_id: i64, name: *const c_char) -> i64 {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.last_insert_id(),
Some(Conn::Postgres(c)) => c.last_insert_id(cstr_arg(name)),
@@ -362,7 +362,7 @@ pub unsafe extern "C" fn elephc_pdo_last_insert_id(conn_id: i64, name: *const c_
/// Returns the number of rows changed by the most recent statement.
#[no_mangle]
pub extern "C" fn elephc_pdo_changes(conn_id: i64) -> i64 {
- let guard = conns().lock().unwrap();
+ let guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&conn_id) {
Some(Conn::Sqlite(c)) => c.changes(),
Some(Conn::Postgres(c)) => c.changes,
@@ -374,7 +374,7 @@ pub extern "C" fn elephc_pdo_changes(conn_id: i64) -> i64 {
/// Begins a transaction (`PDO::beginTransaction`). Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_begin(conn_id: i64) -> i64 {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.exec_simple(b"BEGIN"),
Some(Conn::Postgres(c)) => c.exec_simple("BEGIN"),
@@ -386,7 +386,7 @@ pub extern "C" fn elephc_pdo_begin(conn_id: i64) -> i64 {
/// Commits the active transaction (`PDO::commit`). Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_commit(conn_id: i64) -> i64 {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.exec_simple(b"COMMIT"),
Some(Conn::Postgres(c)) => c.exec_simple("COMMIT"),
@@ -398,7 +398,7 @@ pub extern "C" fn elephc_pdo_commit(conn_id: i64) -> i64 {
/// Rolls back the active transaction (`PDO::rollBack`). Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_rollback(conn_id: i64) -> i64 {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.exec_simple(b"ROLLBACK"),
Some(Conn::Postgres(c)) => c.exec_simple("ROLLBACK"),
@@ -410,7 +410,7 @@ pub extern "C" fn elephc_pdo_rollback(conn_id: i64) -> i64 {
/// Returns the driver's result code for the connection's last operation.
#[no_mangle]
pub extern "C" fn elephc_pdo_errcode(conn_id: i64) -> i64 {
- let guard = conns().lock().unwrap();
+ let guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&conn_id) {
Some(Conn::Sqlite(c)) => c.errcode(),
Some(Conn::Postgres(c)) => c.errcode,
@@ -424,7 +424,7 @@ pub extern "C" fn elephc_pdo_errcode(conn_id: i64) -> i64 {
#[no_mangle]
pub extern "C" fn elephc_pdo_errmsg(conn_id: i64) -> *const c_char {
let msg = {
- let guard = conns().lock().unwrap();
+ let guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&conn_id) {
Some(Conn::Sqlite(c)) => c.errmsg(),
Some(Conn::Postgres(c)) => c.errmsg.clone(),
@@ -443,7 +443,7 @@ pub extern "C" fn elephc_pdo_errmsg(conn_id: i64) -> *const c_char {
#[no_mangle]
pub unsafe extern "C" fn elephc_pdo_prepare(conn_id: i64, sql: *const c_char) -> i64 {
let prepared: Result = {
- let mut guard = conns().lock().unwrap();
+ let mut guard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&conn_id) {
Some(Conn::Sqlite(c)) => c.prepare(sql).map(Stmt::Sqlite),
Some(Conn::Postgres(c)) => match cstr_arg(sql) {
@@ -472,7 +472,7 @@ pub unsafe extern "C" fn elephc_pdo_prepare(conn_id: i64, sql: *const c_char) ->
match prepared {
Ok(stmt) => {
let id = next_id();
- stmts().lock().unwrap().insert(id, stmt);
+ stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(id, stmt);
id
}
Err(()) => -1,
@@ -485,7 +485,7 @@ pub unsafe extern "C" fn elephc_pdo_prepare(conn_id: i64, sql: *const c_char) ->
/// `name` must point to a NUL-terminated string valid for the duration of the call.
#[no_mangle]
pub unsafe extern "C" fn elephc_pdo_bind_parameter_index(stmt_id: i64, name: *const c_char) -> i64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let Some(name) = cstr_arg(name) else {
return 0;
};
@@ -500,7 +500,7 @@ pub unsafe extern "C" fn elephc_pdo_bind_parameter_index(stmt_id: i64, name: *co
/// Binds an integer to the 1-based placeholder `idx`. Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_bind_int(stmt_id: i64, idx: i64, val: i64) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.bind_int(idx, val),
Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Int(val)),
@@ -512,7 +512,7 @@ pub extern "C" fn elephc_pdo_bind_int(stmt_id: i64, idx: i64, val: i64) -> i64 {
/// Binds a double to the 1-based placeholder `idx`. Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_bind_double(stmt_id: i64, idx: i64, val: f64) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.bind_double(idx, val),
Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Float(val)),
@@ -528,7 +528,7 @@ pub extern "C" fn elephc_pdo_bind_double(stmt_id: i64, idx: i64, val: f64) -> i6
/// `val`, when non-null, must point to a NUL-terminated string valid for the call.
#[no_mangle]
pub unsafe extern "C" fn elephc_pdo_bind_text(stmt_id: i64, idx: i64, val: *const c_char) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.bind_text(idx, val),
Some(Stmt::Postgres(s)) => {
@@ -552,7 +552,7 @@ pub unsafe extern "C" fn elephc_pdo_bind_text(stmt_id: i64, idx: i64, val: *cons
/// Binds SQL NULL to the 1-based placeholder `idx`. Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_bind_null(stmt_id: i64, idx: i64) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.bind_null(idx),
Some(Stmt::Postgres(s)) => s.bind(idx, pg::Bind::Null),
@@ -564,7 +564,7 @@ pub extern "C" fn elephc_pdo_bind_null(stmt_id: i64, idx: i64) -> i64 {
/// Resets a statement, keeping its parameter bindings. Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_reset(stmt_id: i64) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.reset(),
Some(Stmt::Postgres(s)) => s.reset(),
@@ -576,7 +576,7 @@ pub extern "C" fn elephc_pdo_reset(stmt_id: i64) -> i64 {
/// Clears all parameter bindings on a statement. Returns `1`/`0`.
#[no_mangle]
pub extern "C" fn elephc_pdo_clear_bindings(stmt_id: i64) -> i64 {
- let mut guard = stmts().lock().unwrap();
+ let mut guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.clear_bindings(),
Some(Stmt::Postgres(s)) => s.clear_bindings(),
@@ -589,12 +589,12 @@ pub extern "C" fn elephc_pdo_clear_bindings(stmt_id: i64) -> i64 {
/// error.
#[no_mangle]
pub extern "C" fn elephc_pdo_step(stmt_id: i64) -> i64 {
- let mut sguard = stmts().lock().unwrap();
+ let mut sguard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match sguard.get_mut(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.step(),
Some(Stmt::Postgres(s)) => {
let conn_id = s.conn_id;
- let mut cguard = conns().lock().unwrap();
+ let mut cguard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match cguard.get_mut(&conn_id) {
Some(Conn::Postgres(c)) => s.step(c),
_ => -1,
@@ -602,7 +602,7 @@ pub extern "C" fn elephc_pdo_step(stmt_id: i64) -> i64 {
}
Some(Stmt::Mysql(s)) => {
let conn_id = s.conn_id;
- let mut cguard = conns().lock().unwrap();
+ let mut cguard = conns().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match cguard.get_mut(&conn_id) {
Some(Conn::Mysql(c)) => s.step(c),
_ => -1,
@@ -615,7 +615,7 @@ pub extern "C" fn elephc_pdo_step(stmt_id: i64) -> i64 {
/// Returns the number of result columns for the statement.
#[no_mangle]
pub extern "C" fn elephc_pdo_column_count(stmt_id: i64) -> i64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_count(),
Some(Stmt::Postgres(s)) => s.column_count(),
@@ -628,7 +628,7 @@ pub extern "C" fn elephc_pdo_column_count(stmt_id: i64) -> i64 {
#[no_mangle]
pub extern "C" fn elephc_pdo_column_name(stmt_id: i64, i: i64) -> *const c_char {
let name = {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_name(i),
Some(Stmt::Postgres(s)) => s.column_name(i),
@@ -643,7 +643,7 @@ pub extern "C" fn elephc_pdo_column_name(stmt_id: i64, i: i64) -> *const c_char
/// (0-based): 1=int, 2=float, 3=text, 4=blob/bytea, 5=null.
#[no_mangle]
pub extern "C" fn elephc_pdo_column_type(stmt_id: i64, i: i64) -> i64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_type(i),
Some(Stmt::Postgres(s)) => s.column_type(i),
@@ -655,7 +655,7 @@ pub extern "C" fn elephc_pdo_column_type(stmt_id: i64, i: i64) -> i64 {
/// Returns the current row's column `i` (0-based) as an integer.
#[no_mangle]
pub extern "C" fn elephc_pdo_column_int(stmt_id: i64, i: i64) -> i64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_int(i),
Some(Stmt::Postgres(s)) => s.column_int(i),
@@ -667,7 +667,7 @@ pub extern "C" fn elephc_pdo_column_int(stmt_id: i64, i: i64) -> i64 {
/// Returns the current row's column `i` (0-based) as a double.
#[no_mangle]
pub extern "C" fn elephc_pdo_column_double(stmt_id: i64, i: i64) -> f64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_double(i),
Some(Stmt::Postgres(s)) => s.column_double(i),
@@ -680,7 +680,7 @@ pub extern "C" fn elephc_pdo_column_double(stmt_id: i64, i: i64) -> f64 {
#[no_mangle]
pub extern "C" fn elephc_pdo_column_text(stmt_id: i64, i: i64) -> *const c_char {
let text = {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_text(i),
Some(Stmt::Postgres(s)) => s.column_text(i),
@@ -696,7 +696,7 @@ pub extern "C" fn elephc_pdo_column_text(stmt_id: i64, i: i64) -> *const c_char
/// NUL bytes when paired with `elephc_pdo_column_data_ptr`.
#[no_mangle]
pub extern "C" fn elephc_pdo_column_data_len(stmt_id: i64, i: i64) -> i64 {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_data(i).len() as i64,
Some(Stmt::Postgres(s)) => s.column_data(i).len() as i64,
@@ -710,7 +710,7 @@ pub extern "C" fn elephc_pdo_column_data_len(stmt_id: i64, i: i64) -> i64 {
#[no_mangle]
pub extern "C" fn elephc_pdo_column_data_ptr(stmt_id: i64, i: i64) -> *const c_char {
let bytes = {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_data(i),
Some(Stmt::Postgres(s)) => s.column_data(i),
@@ -729,7 +729,7 @@ pub extern "C" fn elephc_pdo_column_data_byte(stmt_id: i64, i: i64, offset: i64)
return 0;
};
let bytes = {
- let guard = stmts().lock().unwrap();
+ let guard = stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
match guard.get(&stmt_id) {
Some(Stmt::Sqlite(s)) => s.column_data(i),
Some(Stmt::Postgres(s)) => s.column_data(i),
@@ -744,7 +744,7 @@ pub extern "C" fn elephc_pdo_column_data_byte(stmt_id: i64, i: i64, offset: i64)
/// `0`; success returns `1`.
#[no_mangle]
pub extern "C" fn elephc_pdo_finalize(stmt_id: i64) -> i64 {
- match stmts().lock().unwrap().remove(&stmt_id) {
+ match stmts().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&stmt_id) {
Some(Stmt::Sqlite(s)) => {
s.finalize();
1
@@ -895,6 +895,89 @@ mod tests {
assert_eq!(elephc_pdo_finalize(stmt), 1);
}
+ /// A SQLite DSN containing non-ASCII path components creates and reopens
+ /// the same database, exercising the UTF-8 filename contract used by
+ /// SQLite's UTF-16 Windows VFS boundary.
+ #[test]
+ fn sqlite_unicode_path_round_trip() {
+ let root = std::env::temp_dir().join(format!(
+ "elephc-pdo-unicode-{}-Données-日本語",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&root);
+ std::fs::create_dir_all(&root).expect("create unicode sqlite test directory");
+ let path = root.join("base-éè-東京.sqlite");
+ let dsn = cs(&format!("sqlite:{}", path.display()));
+ let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) };
+ assert!(conn > 0, "unicode sqlite open failed");
+ let ddl = cs("CREATE TABLE unicode_data (value TEXT)");
+ assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0);
+ let insert = cs("INSERT INTO unicode_data VALUES ('été 日本語')");
+ assert_eq!(unsafe { elephc_pdo_exec(conn, insert.as_ptr()) }, 1);
+ elephc_pdo_close(conn);
+
+ let reopened = unsafe { elephc_pdo_open(dsn.as_ptr()) };
+ assert!(reopened > 0, "unicode sqlite reopen failed");
+ let sql = cs("SELECT value FROM unicode_data");
+ let stmt = unsafe { elephc_pdo_prepare(reopened, sql.as_ptr()) };
+ assert_eq!(elephc_pdo_step(stmt), 1);
+ assert_eq!(unsafe { read(elephc_pdo_column_text(stmt, 0)) }, "été 日本語");
+ elephc_pdo_finalize(stmt);
+ elephc_pdo_close(reopened);
+ let _ = std::fs::remove_dir_all(root);
+ }
+
+ /// SQLite transaction errors are observable: rollback removes pending
+ /// writes, commit persists successful writes, and an invalid extra commit
+ /// returns false rather than reporting a false success.
+ #[test]
+ fn sqlite_transactions_commit_rollback_and_error() {
+ let dsn = cs("sqlite::memory:");
+ let conn = unsafe { elephc_pdo_open(dsn.as_ptr()) };
+ let ddl = cs("CREATE TABLE tx (n INTEGER)");
+ assert_eq!(unsafe { elephc_pdo_exec(conn, ddl.as_ptr()) }, 0);
+ assert_eq!(elephc_pdo_begin(conn), 1);
+ let first = cs("INSERT INTO tx VALUES (1)");
+ assert_eq!(unsafe { elephc_pdo_exec(conn, first.as_ptr()) }, 1);
+ assert_eq!(elephc_pdo_rollback(conn), 1);
+ assert_eq!(elephc_pdo_begin(conn), 1);
+ let second = cs("INSERT INTO tx VALUES (2)");
+ assert_eq!(unsafe { elephc_pdo_exec(conn, second.as_ptr()) }, 1);
+ assert_eq!(elephc_pdo_commit(conn), 1);
+ assert_eq!(elephc_pdo_commit(conn), 0, "commit without transaction must fail");
+ assert_ne!(elephc_pdo_errcode(conn), 0);
+ elephc_pdo_close(conn);
+ }
+
+ /// Two file-backed connections surface SQLite's lock error while one owns
+ /// an immediate write transaction; the blocked writer must return `-1` and
+ /// expose a non-zero error code instead of claiming success.
+ #[test]
+ fn sqlite_file_lock_reports_busy_error() {
+ let path = std::env::temp_dir().join(format!(
+ "elephc-pdo-lock-{}.sqlite",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_file(&path);
+ let dsn = cs(&format!("sqlite:{}", path.display()));
+ let first = unsafe { elephc_pdo_open(dsn.as_ptr()) };
+ let second = unsafe { elephc_pdo_open(dsn.as_ptr()) };
+ let ddl = cs("CREATE TABLE locked (n INTEGER)");
+ assert_eq!(unsafe { elephc_pdo_exec(first, ddl.as_ptr()) }, 0);
+ let short_timeout = cs("PRAGMA busy_timeout=1");
+ assert_eq!(unsafe { elephc_pdo_exec(second, short_timeout.as_ptr()) }, 0);
+ let begin = cs("BEGIN IMMEDIATE");
+ assert_eq!(unsafe { elephc_pdo_exec(first, begin.as_ptr()) }, 0);
+ let insert = cs("INSERT INTO locked VALUES (1)");
+ assert_eq!(unsafe { elephc_pdo_exec(second, insert.as_ptr()) }, -1);
+ assert_ne!(elephc_pdo_errcode(second), 0);
+ let rollback = cs("ROLLBACK");
+ assert_eq!(unsafe { elephc_pdo_exec(first, rollback.as_ptr()) }, 0);
+ elephc_pdo_close(first);
+ elephc_pdo_close(second);
+ let _ = std::fs::remove_file(path);
+ }
+
/// Placeholder translation: `?` → `$1`, `:name` → `$N` (deduped), with
/// `'…'` literals and the `::` cast operator left untouched.
#[test]
diff --git a/crates/elephc-pdo/src/my.rs b/crates/elephc-pdo/src/my.rs
index 142604c144..7b1c011179 100644
--- a/crates/elephc-pdo/src/my.rs
+++ b/crates/elephc-pdo/src/my.rs
@@ -502,7 +502,11 @@ impl MyStmt {
let mut rows = Vec::new();
if is_select {
for row in res.by_ref() {
- rows.push(decode_row(row?.unwrap(), &col_kinds));
+ let row = row?;
+ let values = (0..row.len())
+ .map(|index| row.as_ref(index).cloned().unwrap_or(Value::NULL))
+ .collect();
+ rows.push(decode_row(values, &col_kinds));
}
}
let affected = res.affected_rows() as i64;
diff --git a/crates/elephc-pdo/src/sqlite.rs b/crates/elephc-pdo/src/sqlite.rs
index d1db60f357..85e1b3600e 100644
--- a/crates/elephc-pdo/src/sqlite.rs
+++ b/crates/elephc-pdo/src/sqlite.rs
@@ -11,6 +11,10 @@
//! - SQLite is statically bundled (`libsqlite3-sys`'s `bundled` feature), so a
//! compiled PHP binary that links this staticlib has no system SQLite runtime
//! dependency.
+//! - Paths stay UTF-8 at the bridge boundary. SQLite's Windows VFS converts its
+//! UTF-8 filename API to native UTF-16, preserving non-ASCII DSN paths.
+//! - Connections use FULLMUTEX and a bounded busy timeout so concurrent Windows
+//! file locks report a real SQLite error instead of a false success or spin.
//! - Column type codes match SQLite's: 1=INTEGER, 2=FLOAT, 3=TEXT, 4=BLOB,
//! 5=NULL — the same codes the PDO prelude's `columnValue()` reads.
@@ -51,7 +55,7 @@ impl SqliteConn {
return Err("invalid database path".to_string());
};
let mut db: *mut ffi::sqlite3 = ptr::null_mut();
- let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE;
+ let flags = ffi::SQLITE_OPEN_READWRITE | ffi::SQLITE_OPEN_CREATE | ffi::SQLITE_OPEN_FULLMUTEX;
let rc = unsafe { ffi::sqlite3_open_v2(c_path.as_ptr(), &mut db, flags, ptr::null()) };
if rc != ffi::SQLITE_OK {
let msg = if db.is_null() {
@@ -64,6 +68,13 @@ impl SqliteConn {
}
return Err(msg);
}
+ let timeout_rc = unsafe { ffi::sqlite3_busy_timeout(db, 5_000) };
+ if timeout_rc != ffi::SQLITE_OK {
+ let msg = unsafe { read_errmsg(db) };
+ unsafe { ffi::sqlite3_close(db) };
+ return Err(msg);
+ }
+ unsafe { ffi::sqlite3_extended_result_codes(db, 1) };
Ok(SqliteConn { db })
}
diff --git a/crates/elephc-phar/Cargo.toml b/crates/elephc-phar/Cargo.toml
index e415fbed71..14d0d497b6 100644
--- a/crates/elephc-phar/Cargo.toml
+++ b/crates/elephc-phar/Cargo.toml
@@ -17,3 +17,6 @@ sha1 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
rsa = { version = "0.9", features = ["sha1", "sha2", "pem"] }
+# Supplies the ZipCrypto header nonce from the operating system CSPRNG. On
+# Windows this uses the platform random provider instead of time-derived bytes.
+getrandom = "0.2"
diff --git a/crates/elephc-phar/src/lib.rs b/crates/elephc-phar/src/lib.rs
index a2f2256024..1ae77e89d9 100644
--- a/crates/elephc-phar/src/lib.rs
+++ b/crates/elephc-phar/src/lib.rs
@@ -22,9 +22,7 @@
//! compatibility, not as a real confidentiality mechanism).
use std::io::{Read, Write};
-use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
-use std::time::{SystemTime, UNIX_EPOCH};
const PHAR_FLAG_GZIP: u32 = 0x0000_1000;
const PHAR_FLAG_BZIP2: u32 = 0x0000_2000;
@@ -455,7 +453,8 @@ pub unsafe extern "C" fn elephc_phar_delete_url(
/// C ABI wrapper around [`set_zip_password`].
///
/// Sets the password used to read and write traditional-PKWARE (ZipCrypto)
-/// encrypted ZIP entries; an empty password clears it. Always returns `1`.
+/// encrypted ZIP entries; an empty password clears it. Returns `1` on success
+/// and `0` if the bridge catches an unexpected panic.
///
/// # Safety
/// `password_ptr` must be valid for `password_len` bytes unless `password_len` is zero.
@@ -464,8 +463,9 @@ pub unsafe extern "C" fn elephc_phar_set_zip_password(
password_ptr: *const u8,
password_len: usize,
) -> usize {
- let _ = std::panic::catch_unwind(|| set_zip_password(slice(password_ptr, password_len)));
- 1
+ usize::from(
+ std::panic::catch_unwind(|| set_zip_password(slice(password_ptr, password_len))).is_ok(),
+ )
}
/// C ABI wrapper around [`set_archive_compression`].
@@ -894,7 +894,7 @@ fn publish_result(bytes: Vec, out_len: *mut usize) -> *const u8 {
let mut buffer = EXTRACT_BUFFER
.get_or_init(|| Mutex::new(Vec::new()))
.lock()
- .expect("elephc_phar extract buffer poisoned");
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
buffer.clear();
buffer.extend_from_slice(&bytes);
write_len(out_len, buffer.len());
@@ -916,7 +916,9 @@ fn write_streams() -> &'static Mutex>> {
/// Allocates a write-stream slot and returns its synthetic descriptor.
fn allocate_write_stream(stream: WriteStream) -> Option {
- let mut streams = write_streams().lock().ok()?;
+ let mut streams = write_streams()
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
for (slot, current) in streams.iter_mut().enumerate() {
if current.is_none() {
*current = Some(stream);
@@ -935,7 +937,9 @@ fn write_stream_slot(fd: usize) -> Option {
/// Appends payload bytes to an open write stream.
fn append_write_stream(fd: usize, data: &[u8]) -> Option {
let slot = write_stream_slot(fd)?;
- let mut streams = write_streams().lock().ok()?;
+ let mut streams = write_streams()
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
let stream = streams.get_mut(slot)?.as_mut()?;
stream.payload.extend_from_slice(data);
Some(data.len())
@@ -945,7 +949,9 @@ fn append_write_stream(fd: usize, data: &[u8]) -> Option {
fn finalize_write_stream(fd: usize) -> Option<()> {
let slot = write_stream_slot(fd)?;
let stream = {
- let mut streams = write_streams().lock().ok()?;
+ let mut streams = write_streams()
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
streams.get_mut(slot)?.take()?
};
match stream.target {
@@ -1659,7 +1665,7 @@ fn write_zip_entry(
let password = if encrypt { current_zip_password() } else { None };
let (stored, flags) = match password {
Some(pw) => (
- zipcrypto_encrypt(&pw, &stored, (crc >> 24) as u8),
+ zipcrypto_encrypt(&pw, &stored, (crc >> 24) as u8)?,
ZIP_FLAG_ENCRYPTED,
),
None => (stored, 0u16),
@@ -1810,10 +1816,14 @@ fn strip_signature_trailer(archive: &[u8]) -> &[u8] {
if n < 8 || &archive[n - 4..] != b"GBMB" {
return archive;
}
- let flags = u32::from_le_bytes(archive[n - 8..n - 4].try_into().unwrap());
+ let Some(flags) = le32(archive, n - 8) else {
+ return archive;
+ };
if flags == PHAR_OPENSSL_SIGNATURE_TYPE {
if n >= 12 {
- let sig_len = u32::from_le_bytes(archive[n - 12..n - 8].try_into().unwrap()) as usize;
+ let Some(sig_len) = le32(archive, n - 12).map(|len| len as usize) else {
+ return archive;
+ };
if let Some(total) = sig_len.checked_add(12) {
if n >= total {
return &archive[..n - total];
@@ -2030,10 +2040,9 @@ fn read_signature_info(path: &[u8]) -> Option<(u32, Vec)> {
if n < 8 || &data[n - 4..] != b"GBMB" {
return None;
}
- let flags = u32::from_le_bytes(data[n - 8..n - 4].try_into().unwrap());
+ let flags = le32(&data, n - 8)?;
if flags == PHAR_OPENSSL_SIGNATURE_TYPE {
- let sig_len =
- u32::from_le_bytes(data.get(n - 12..n - 8)?.try_into().unwrap()) as usize;
+ let sig_len = le32(&data, n.checked_sub(12)?)? as usize;
let start = n.checked_sub(12)?.checked_sub(sig_len)?;
Some((flags, data.get(start..n - 12)?.to_vec()))
} else {
@@ -2414,39 +2423,19 @@ fn zipcrypto_decrypt(password: &[u8], data: &[u8], check_byte: u8) -> Option Vec {
+/// not round-trip correctness. Returns `None` if the operating-system CSPRNG is
+/// unavailable; callers then fail the archive write instead of emitting a
+/// predictable encryption header.
+fn zipcrypto_encrypt(password: &[u8], data: &[u8], check_byte: u8) -> Option> {
let mut header = [0u8; 12];
- header[..11].copy_from_slice(&zipcrypto_header_filler());
+ getrandom::getrandom(&mut header[..11]).ok()?;
header[11] = check_byte;
let mut keys = ZipCryptoKeys::new(password);
let mut out = Vec::with_capacity(data.len() + 12);
for &plain in header.iter().chain(data) {
out.push(keys.encrypt(plain));
}
- out
-}
-
-/// Produces 11 non-constant filler bytes for a ZipCrypto encryption header, mixing
-/// a per-call atomic nonce with the current time through an xorshift64* step.
-/// Dependency-free; only needs to avoid an all-constant header, since the bytes are
-/// discarded on read.
-fn zipcrypto_header_filler() -> [u8; 11] {
- static NONCE: AtomicU64 = AtomicU64::new(0);
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|d| d.as_nanos() as u64)
- .unwrap_or(0);
- let mut state = now ^ NONCE.fetch_add(1, Ordering::Relaxed).wrapping_mul(0x9E37_79B9_7F4A_7C15);
- let mut filler = [0u8; 11];
- for byte in filler.iter_mut() {
- // xorshift64* advance, then take a high byte of the scrambled state.
- state ^= state >> 12;
- state ^= state << 25;
- state ^= state >> 27;
- *byte = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 33) as u8;
- }
- filler
+ Some(out)
}
/// Returns the password currently set for reading and writing encrypted ZIP
@@ -3077,7 +3066,8 @@ mod tests {
let crc = crc32(content);
// Reuse the production encryptor so the test fixture and the writer share a
// single cipher direction (check byte = the CRC's high byte, no descriptor).
- let enc = zipcrypto_encrypt(password, content, (crc >> 24) as u8);
+ let enc = zipcrypto_encrypt(password, content, (crc >> 24) as u8)
+ .expect("test host must expose operating-system entropy");
let csz = enc.len() as u32;
let usz = content.len() as u32;
let mut out = Vec::new();
@@ -3206,6 +3196,26 @@ mod tests {
assert_eq!(zip_local_flag(&plain, b"a.txt"), Some(0));
}
+ /// Verifies separate ZipCrypto writes receive independent system-random
+ /// headers while both ciphertexts remain decryptable with the same key.
+ #[test]
+ fn zipcrypto_headers_use_fresh_system_entropy() {
+ let password = b"secret";
+ let payload = b"same plaintext";
+ let check_byte = (crc32(payload) >> 24) as u8;
+ let first = zipcrypto_encrypt(password, payload, check_byte)
+ .expect("test host must expose operating-system entropy");
+ let second = zipcrypto_encrypt(password, payload, check_byte)
+ .expect("test host must expose operating-system entropy");
+
+ assert_ne!(&first[..12], &second[..12]);
+ assert_eq!(zipcrypto_decrypt(password, &first, check_byte).as_deref(), Some(&payload[..]));
+ assert_eq!(
+ zipcrypto_decrypt(password, &second, check_byte).as_deref(),
+ Some(&payload[..])
+ );
+ }
+
/// Signing a zip phar whose entries are encrypted still produces a readable
/// `.phar/signature.bin`: the signed range covers the encrypted bytes, the entry
/// decrypts with the password, the signature reports SHA-256, and the signature
@@ -4090,4 +4100,38 @@ rNiobfy8sSb6iw==\n\
assert_eq!(set_stub_bytes(pb.as_bytes(), b" isize {
+ match error.kind() {
+ std::io::ErrorKind::WouldBlock => TLS_IO_WOULD_BLOCK,
+ std::io::ErrorKind::TimedOut => TLS_IO_TIMED_OUT,
+ _ => TLS_IO_TERMINAL,
+ }
+}
+
+/// Converts a Rust TLS read error into the stable read-side ABI result.
+///
+/// php-src enables OpenSSL's `SSL_OP_IGNORE_UNEXPECTED_EOF` for stream
+/// clients. Rustls reports the equivalent peer TCP close without a
+/// `close_notify` alert as an error, but PHP surfaces the bytes already read
+/// and marks the stream at EOF. Writes retain the terminal-error mapping.
+fn tls_read_error_result(error: &std::io::Error) -> isize {
+ if error.kind() == std::io::ErrorKind::UnexpectedEof {
+ 0
+ } else {
+ tls_io_error_result(error)
+ }
+}
+
+/// Returns the elephc-tls ABI version.
+///
+/// v2 adds `elephc_tls_handshake`, whose tri-state result lets PHP's
+/// `stream_socket_enable_crypto()` distinguish completion, nonblocking
+/// progress, and failure without discarding the live rustls connection.
+/// v3 adds the fixed-layout option block shared by connect and attach.
#[no_mangle]
pub extern "C" fn elephc_tls_version() -> i32 {
- 1
+ 3
}
struct HandleEntry {
sock: TcpStream,
conn: ClientConnection,
+ config: Arc,
}
/// Returns the process-wide TLS handle table guarded by a mutex.
@@ -59,99 +174,393 @@ fn next_handle_id() -> i64 {
NEXT_ID.fetch_add(1, Ordering::SeqCst)
}
-/// `ServerCertVerifier` that accepts any certificate. Used by
-/// `elephc_tls_connect_insecure` when the caller has set the
-/// `ssl.verify_peer = false` stream context option. v1 trade-off: TLS still
-/// encrypts the channel, but the peer identity is no longer authenticated.
+/// Inserts one live TLS stream into the process handle table, returning `-1`
+/// if a prior panic poisoned the mutex instead of unwinding through the C ABI.
+fn insert_handle(sock: TcpStream, conn: ClientConnection, config: Arc) -> i64 {
+ let id = next_handle_id();
+ let Ok(mut guard) = handles().lock() else {
+ return -1;
+ };
+ guard.insert(
+ id,
+ Box::new(HandleEntry {
+ sock,
+ conn,
+ config,
+ }),
+ );
+ id
+}
+
+/// Clones the rustls client configuration retained by one live source session.
+///
+/// Reusing the same `Arc` also shares rustls's client-session
+/// cache, mirroring php-src's SSL context and session reuse for
+/// `stream_socket_enable_crypto(..., session_stream: $source)`.
+fn session_client_config(handle_id: i64) -> Option> {
+ if handle_id <= 0 {
+ return None;
+ }
+ let guard = handles().lock().ok()?;
+ Some(guard.get(&handle_id)?.config.clone())
+}
+
+/// Independent PHP-compatible peer-verification switches decoded from the C ABI.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct VerificationPolicy {
+ verify_peer: bool,
+ verify_peer_name: bool,
+ allow_self_signed: bool,
+}
+
+impl VerificationPolicy {
+ /// Decodes the stable verification bitset, rejecting unknown future bits.
+ fn from_flags(flags: u32) -> Option {
+ if flags & !TLS_KNOWN_VERIFY_FLAGS != 0 {
+ return None;
+ }
+ Some(Self {
+ verify_peer: flags & TLS_VERIFY_PEER != 0,
+ verify_peer_name: flags & TLS_VERIFY_PEER_NAME != 0,
+ allow_self_signed: flags & TLS_ALLOW_SELF_SIGNED != 0,
+ })
+ }
+}
+
+/// Parsed TLS configuration shared by connect and existing-socket attachment.
+#[derive(Clone, Copy, Debug)]
+struct TlsOptions<'a> {
+ verification_flags: u32,
+ cafile: Option<&'a str>,
+ capath: Option<&'a str>,
+ client_cert: Option<&'a str>,
+ client_key: Option<&'a str>,
+}
+
+/// Fixed-layout TLS option block shared by connect and existing-socket attach.
+///
+/// The x86_64/aarch64 ABI is 88 bytes: two `u32` header fields followed by five
+/// pointer/length pairs at offsets 8/16, 24/32, 40/48, 56/64, and 72/80.
+#[repr(C)]
+#[derive(Clone, Copy, Debug)]
+pub struct ElephcTlsClientOptions {
+ /// Must equal `TLS_CLIENT_OPTIONS_ABI_VERSION`.
+ pub abi_version: u32,
+ /// Bitset composed from `TLS_VERIFY_*` and `TLS_ALLOW_SELF_SIGNED`.
+ pub verification_flags: u32,
+ /// Optional SNI and certificate-name override.
+ pub peer_name_ptr: *const u8,
+ /// Byte length of `peer_name_ptr`.
+ pub peer_name_len: usize,
+ /// Optional PEM CA bundle path.
+ pub cafile_ptr: *const u8,
+ /// Byte length of `cafile_ptr`.
+ pub cafile_len: usize,
+ /// Optional CA-directory path.
+ pub capath_ptr: *const u8,
+ /// Byte length of `capath_ptr`.
+ pub capath_len: usize,
+ /// Optional PEM client certificate path.
+ pub cert_ptr: *const u8,
+ /// Byte length of `cert_ptr`.
+ pub cert_len: usize,
+ /// Optional PEM client private-key path.
+ pub key_ptr: *const u8,
+ /// Byte length of `key_ptr`.
+ pub key_len: usize,
+}
+
+impl TlsOptions<'_> {
+ /// Returns the secure built-in-root configuration used by legacy wrappers.
+ fn secure_defaults() -> Self {
+ Self {
+ verification_flags: TLS_DEFAULT_VERIFY_FLAGS,
+ cafile: None,
+ capath: None,
+ client_cert: None,
+ client_key: None,
+ }
+ }
+}
+
+/// Certificate verifier that keeps chain validation, peer-name validation, and
+/// handshake-signature validation independent, matching PHP stream options.
#[derive(Debug)]
-struct NoVerification;
+struct PolicyVerifier {
+ roots: Arc,
+ policy: VerificationPolicy,
+ supported: WebPkiSupportedAlgorithms,
+}
-impl ServerCertVerifier for NoVerification {
- /// Accepts a server certificate in the explicit insecure verifier path.
+impl ServerCertVerifier for PolicyVerifier {
+ /// Applies the selected chain and name policy without weakening TLS
+ /// CertificateVerify signature checks.
fn verify_server_cert(
&self,
- _end_entity: &CertificateDer<'_>,
- _intermediates: &[CertificateDer<'_>],
- _server_name: &ServerName<'_>,
+ end_entity: &CertificateDer<'_>,
+ intermediates: &[CertificateDer<'_>],
+ server_name: &ServerName<'_>,
_ocsp: &[u8],
- _now: UnixTime,
+ now: UnixTime,
) -> Result {
+ let cert = ParsedCertificate::try_from(end_entity)?;
+ if self.policy.verify_peer {
+ let chain_result = verify_server_cert_signed_by_trust_anchor(
+ &cert,
+ &self.roots,
+ intermediates,
+ now,
+ self.supported.all,
+ );
+ if let Err(chain_error) = chain_result {
+ if !self.policy.allow_self_signed
+ || verify_depth_zero_self_signed(
+ end_entity,
+ &cert,
+ now,
+ self.supported.all,
+ )
+ .is_err()
+ {
+ return Err(chain_error);
+ }
+ }
+ }
+ if self.policy.verify_peer_name {
+ verify_server_name(&cert, server_name)?;
+ }
Ok(ServerCertVerified::assertion())
}
- /// Accepts TLS 1.2 signatures in the explicit insecure verifier path.
+ /// Cryptographically validates every TLS 1.2 CertificateVerify signature,
+ /// including when peer-chain and peer-name checks are disabled.
fn verify_tls12_signature(
&self,
- _message: &[u8],
- _cert: &CertificateDer<'_>,
- _dss: &DigitallySignedStruct,
+ message: &[u8],
+ cert: &CertificateDer<'_>,
+ dss: &DigitallySignedStruct,
) -> Result {
- Ok(HandshakeSignatureValid::assertion())
+ verify_tls12_signature(message, cert, dss, &self.supported)
}
- /// Accepts TLS 1.3 signatures in the explicit insecure verifier path.
+ /// Cryptographically validates every TLS 1.3 CertificateVerify signature,
+ /// including when peer-chain and peer-name checks are disabled.
fn verify_tls13_signature(
&self,
- _message: &[u8],
- _cert: &CertificateDer<'_>,
- _dss: &DigitallySignedStruct,
+ message: &[u8],
+ cert: &CertificateDer<'_>,
+ dss: &DigitallySignedStruct,
) -> Result {
- Ok(HandshakeSignatureValid::assertion())
+ verify_tls13_signature(message, cert, dss, &self.supported)
}
- /// Reports the signature schemes accepted by the insecure verifier.
+ /// Reports exactly the signature schemes implemented by the ring provider.
fn supported_verify_schemes(&self) -> Vec {
- vec![
- SignatureScheme::RSA_PKCS1_SHA256,
- SignatureScheme::RSA_PKCS1_SHA384,
- SignatureScheme::RSA_PKCS1_SHA512,
- SignatureScheme::ECDSA_NISTP256_SHA256,
- SignatureScheme::ECDSA_NISTP384_SHA384,
- SignatureScheme::RSA_PSS_SHA256,
- SignatureScheme::RSA_PSS_SHA384,
- SignatureScheme::RSA_PSS_SHA512,
- SignatureScheme::ED25519,
- ]
+ self.supported.supported_schemes()
}
}
-/// Builds a rustls client configuration with certificate verification disabled.
+/// Verifies that a depth-zero certificate is self-issued and signed by its own
+/// key, while still enforcing validity, purpose, and supported algorithms.
+///
+/// rustls-webpki has no dedicated self-signed-leaf API. Building a temporary
+/// one-certificate trust store makes the public path verifier require the
+/// leaf's issuer to equal its subject and verify its signature with its own
+/// SPKI. Unrelated extra certificates do not change that depth-zero property.
+fn verify_depth_zero_self_signed(
+ end_entity: &CertificateDer<'_>,
+ cert: &ParsedCertificate<'_>,
+ now: UnixTime,
+ supported_algs: &[&dyn rustls::pki_types::SignatureVerificationAlgorithm],
+) -> Result<(), RustlsError> {
+ let mut self_anchor = RootCertStore::empty();
+ self_anchor.add(end_entity.clone())?;
+ verify_server_cert_signed_by_trust_anchor(cert, &self_anchor, &[], now, supported_algs)
+}
+
+/// Builds a rustls client configuration with chain and name verification
+/// disabled while retaining TLS handshake-signature verification.
fn insecure_client_config() -> Arc {
static CFG: OnceLock> = OnceLock::new();
CFG.get_or_init(|| {
- let _ = rustls::crypto::ring::default_provider().install_default();
- Arc::new(
- ClientConfig::builder()
- .dangerous()
- .with_custom_certificate_verifier(Arc::new(NoVerification))
- .with_no_client_auth(),
- )
+ let mut options = TlsOptions::secure_defaults();
+ options.verification_flags = 0;
+ policy_client_config(options).expect("built-in TLS configuration must be valid")
})
.clone()
}
+/// Builds the platform-default trust store used for server authentication.
+///
+/// Windows follows php-src and reads the native certificate store so
+/// administrator-installed enterprise roots work. Other targets retain the
+/// deterministic Mozilla-derived bundle used by the existing bridge.
+fn bundled_root_store() -> RootCertStore {
+ let mut roots = RootCertStore::empty();
+ #[cfg(windows)]
+ {
+ let native = rustls_native_certs::load_native_certs();
+ roots.add_parsable_certificates(native.certs);
+ }
+ #[cfg(not(windows))]
+ roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
+ roots
+}
+
+/// Adds every certificate from one PEM bundle, rejecting an unreadable,
+/// malformed, or certificate-free explicit `cafile`.
+fn add_cafile_roots(roots: &mut RootCertStore, cafile_path: &str) -> Option<()> {
+ let pem = std::fs::read(cafile_path).ok()?;
+ let mut reader: &[u8] = &pem;
+ let before = roots.len();
+ for cert in rustls_pemfile::certs(&mut reader) {
+ roots.add(cert.ok()?).ok()?;
+ }
+ (roots.len() > before).then_some(())
+}
+
+/// Adds certificates from PEM files in one explicit `capath`, ignoring
+/// unrelated directory entries but rejecting an unreadable or empty source.
+fn add_capath_roots(roots: &mut RootCertStore, capath: &str) -> Option<()> {
+ let before = roots.len();
+ for entry in std::fs::read_dir(capath).ok()? {
+ let path = match entry {
+ Ok(entry) => entry.path(),
+ Err(_) => continue,
+ };
+ if !path.is_file() {
+ continue;
+ }
+ let pem = match std::fs::read(path) {
+ Ok(pem) => pem,
+ Err(_) => continue,
+ };
+ let mut reader: &[u8] = &pem;
+ for cert in rustls_pemfile::certs(&mut reader).flatten() {
+ let _ = roots.add(cert);
+ }
+ }
+ (roots.len() > before).then_some(())
+}
+
+/// Builds the trust store for one option set. Explicit `cafile` and `capath`
+/// sources are additive to each other; when neither is present the bundled
+/// Mozilla roots are used.
+fn configured_root_store(options: TlsOptions<'_>) -> Option {
+ if options.cafile.is_none() && options.capath.is_none() {
+ return Some(bundled_root_store());
+ }
+ let mut roots = RootCertStore::empty();
+ if let Some(cafile) = options.cafile {
+ add_cafile_roots(&mut roots, cafile)?;
+ }
+ if let Some(capath) = options.capath {
+ add_capath_roots(&mut roots, capath)?;
+ }
+ Some(roots)
+}
+
+/// Loads an unencrypted PEM client certificate chain and private key.
+fn load_client_credentials(
+ cert_path: &str,
+ key_path: &str,
+) -> Option<(Vec>, PrivateKeyDer<'static>)> {
+ let cert_pem = std::fs::read(cert_path).ok()?;
+ let mut cert_reader: &[u8] = &cert_pem;
+ let certs: Vec> = rustls_pemfile::certs(&mut cert_reader)
+ .collect::>()
+ .ok()?;
+ if certs.is_empty() {
+ return None;
+ }
+ let key_pem = std::fs::read(key_path).ok()?;
+ let mut key_reader: &[u8] = &key_pem;
+ let key = rustls_pemfile::private_key(&mut key_reader).ok().flatten()?;
+ Some((certs, key))
+}
+
+/// Builds one rustls configuration from the complete combinable PHP TLS
+/// option set, using a single policy verifier for every configuration.
+fn policy_client_config(options: TlsOptions<'_>) -> Option> {
+ policy_client_config_for_method(options, PHP_STREAM_CRYPTO_TLS_CLIENT)
+}
+
+/// Selects the rustls-supported protocol versions requested by a PHP crypto method.
+fn protocol_versions_for_crypto_method(
+ crypto_method: i64,
+) -> Option<&'static [&'static SupportedProtocolVersion]> {
+ if crypto_method < 0
+ || crypto_method & !PHP_STREAM_CRYPTO_KNOWN_BITS != 0
+ || crypto_method & PHP_STREAM_CRYPTO_IS_CLIENT == 0
+ {
+ return None;
+ }
+ match (
+ crypto_method & PHP_STREAM_CRYPTO_TLS_1_2 != 0,
+ crypto_method & PHP_STREAM_CRYPTO_TLS_1_3 != 0,
+ ) {
+ (true, true) => Some(TLS_1_2_AND_1_3),
+ (true, false) => Some(TLS_1_2_ONLY),
+ (false, true) => Some(TLS_1_3_ONLY),
+ (false, false) => None,
+ }
+}
+
+/// Builds a policy configuration restricted to the requested PHP TLS client versions.
+fn policy_client_config_for_method(
+ options: TlsOptions<'_>,
+ crypto_method: i64,
+) -> Option> {
+ let policy = VerificationPolicy::from_flags(options.verification_flags)?;
+ let protocol_versions = protocol_versions_for_crypto_method(crypto_method)?;
+ // php-src only loads cafile/capath while verify_peer is enabled. A caller
+ // performing name-only or fully relaxed verification must not fail because
+ // an otherwise unused CA path is unreadable.
+ let roots = Arc::new(if policy.verify_peer {
+ configured_root_store(options)?
+ } else {
+ RootCertStore::empty()
+ });
+ let provider = Arc::new(rustls::crypto::ring::default_provider());
+ let supported = provider.signature_verification_algorithms;
+ let verifier = Arc::new(PolicyVerifier {
+ roots,
+ policy,
+ supported,
+ });
+ let wants_client_auth = ClientConfig::builder_with_provider(provider)
+ .with_protocol_versions(protocol_versions)
+ .ok()?
+ .dangerous()
+ .with_custom_certificate_verifier(verifier);
+
+ match options.client_cert {
+ None => Some(Arc::new(wants_client_auth.with_no_client_auth())),
+ Some(cert_path) => {
+ // php-src falls back to local_cert as the private-key source when
+ // local_pk is absent; local_pk alone does not enable client auth.
+ let key_path = options.client_key.unwrap_or(cert_path);
+ let (certs, key) = load_client_credentials(cert_path, key_path)?;
+ wants_client_auth
+ .with_client_auth_cert(certs, key)
+ .ok()
+ .map(Arc::new)
+ }
+ }
+}
+
/// Returns the lazily initialized default rustls client configuration.
fn shared_client_config() -> Arc {
static CFG: OnceLock> = OnceLock::new();
CFG.get_or_init(|| {
- // Install the `ring` crypto provider once per process. Ignore the
- // "already installed" error so multiple connect calls are safe.
- let _ = rustls::crypto::ring::default_provider().install_default();
-
- let mut roots = RootCertStore::empty();
- roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
- Arc::new(
- ClientConfig::builder()
- .with_root_certificates(roots)
- .with_no_client_auth(),
- )
+ policy_client_config(TlsOptions::secure_defaults())
+ .expect("built-in TLS configuration must be valid")
})
.clone()
}
-/// Open a TLS-secured TCP connection to `host:port` and return an integer
-/// handle ID, or `-1` on any failure. The handle is consumed by
-/// `elephc_tls_close`.
+/// Historical secure-default connect wrapper retained for ABI compatibility.
+/// New runtime paths use `elephc_tls_connect_with_options`.
///
/// # Safety
///
@@ -166,11 +575,9 @@ pub unsafe extern "C" fn elephc_tls_connect(
tls_connect_inner(host_ptr, host_len, port, shared_client_config())
}
-/// Variant of `elephc_tls_connect` that uses the `dangerous_configuration`
-/// rustls path with a no-op certificate verifier. Surfaced through the
-/// runtime when the caller has set `ssl.verify_peer = false` on the stream
-/// context. The channel is still encrypted; only the peer identity is
-/// unauthenticated.
+/// Historical no-chain/no-name connect wrapper retained for ABI compatibility.
+/// TLS 1.2/1.3 CertificateVerify signatures remain cryptographically checked;
+/// the uniform runtime expresses this policy with verification flags `0`.
///
/// # Safety
///
@@ -184,35 +591,48 @@ pub unsafe extern "C" fn elephc_tls_connect_insecure(
tls_connect_inner(host_ptr, host_len, port, insecure_client_config())
}
+/// Opens a TCP/TLS session using one uniform, combinable PHP option surface.
+///
+/// `peer_name` overrides the connection host for SNI and optional name
+/// verification. `cafile` and `capath` are additive trust sources. `local_cert`
+/// and `local_pk` enable client authentication; when `local_pk` is absent,
+/// php-src semantics load the private key from `local_cert`.
+///
+/// # Safety
+///
+/// `options_ptr` must point to a readable `ElephcTlsClientOptions`. Every
+/// non-null option pointer must reference its paired byte length for this call.
+/// Text inputs must be UTF-8.
+#[no_mangle]
+pub unsafe extern "C" fn elephc_tls_connect_with_options(
+ host_ptr: *const u8,
+ host_len: usize,
+ port: u16,
+ options_ptr: *const ElephcTlsClientOptions,
+) -> i64 {
+ let Some((options, peer_name)) = tls_options_from_abi(options_ptr) else {
+ return -1;
+ };
+ let Some(config) = policy_client_config(options) else {
+ return -1;
+ };
+ tls_connect_inner_named(host_ptr, host_len, port, config, peer_name)
+}
+
/// Builds a `ClientConfig` whose trust anchors come from the PEM bundle at
/// `cafile_path` instead of the built-in webpki-roots. Returns `None` if the
/// path is unreadable, contains no certificates, or any certificate is
/// malformed — the caller then fails the connect, matching PHP's behavior when
/// `ssl.cafile` cannot be loaded. Not cached: cafile connects are rare.
fn cafile_client_config(cafile_path: &str) -> Option> {
- let _ = rustls::crypto::ring::default_provider().install_default();
- let pem = std::fs::read(cafile_path).ok()?;
- let mut roots = RootCertStore::empty();
- let mut reader: &[u8] = &pem;
- for cert in rustls_pemfile::certs(&mut reader) {
- roots.add(cert.ok()?).ok()?;
- }
- if roots.is_empty() {
- return None;
- }
- Some(Arc::new(
- ClientConfig::builder()
- .with_root_certificates(roots)
- .with_no_client_auth(),
- ))
+ let mut options = TlsOptions::secure_defaults();
+ options.cafile = Some(cafile_path);
+ policy_client_config(options)
}
-/// Variant of `elephc_tls_connect` that authenticates the peer against a custom
-/// CA bundle (the `ssl.cafile` stream-context option) rather than the built-in
-/// webpki-roots trust store. Returns an integer handle ID, or `-1` on any
-/// failure (including an unreadable/empty cafile). The secure and insecure
-/// connect variants ignore the trailing `cafile_*` arguments, so the runtime
-/// can share one call site and just select the function pointer.
+/// Historical cafile-only connect wrapper retained for ABI compatibility.
+/// New runtime paths combine cafile with other options through the uniform
+/// option block. Returns `-1` for an unreadable or empty bundle.
///
/// # Safety
///
@@ -249,43 +669,14 @@ pub unsafe extern "C" fn elephc_tls_connect_cafile(
/// so the caller fails the connect — matching PHP's behavior when `ssl.capath`
/// cannot supply a trust store. Not cached: capath connects are rare.
fn capath_client_config(capath: &str) -> Option> {
- let _ = rustls::crypto::ring::default_provider().install_default();
- let mut roots = RootCertStore::empty();
- for entry in std::fs::read_dir(capath).ok()? {
- let path = match entry {
- Ok(e) => e.path(),
- Err(_) => continue,
- };
- if !path.is_file() {
- continue;
- }
- let pem = match std::fs::read(&path) {
- Ok(b) => b,
- Err(_) => continue,
- };
- let mut reader: &[u8] = &pem;
- // Add every certificate the file yields; ignore malformed/non-cert
- // entries so a stray non-PEM file in the directory is not fatal.
- for cert in rustls_pemfile::certs(&mut reader).flatten() {
- let _ = roots.add(cert);
- }
- }
- if roots.is_empty() {
- return None;
- }
- Some(Arc::new(
- ClientConfig::builder()
- .with_root_certificates(roots)
- .with_no_client_auth(),
- ))
+ let mut options = TlsOptions::secure_defaults();
+ options.capath = Some(capath);
+ policy_client_config(options)
}
-/// Variant of `elephc_tls_connect` that authenticates the peer against the CA
-/// certificates in the directory named by the `ssl.capath` stream-context
-/// option. Returns an integer handle ID, or `-1` on any failure (including an
-/// unreadable/empty directory). Mirrors `elephc_tls_connect_cafile`; the
-/// secure/insecure variants ignore the trailing `capath_*` args so the runtime
-/// shares one call site and just selects the function pointer.
+/// Historical capath-only connect wrapper retained for ABI compatibility.
+/// New runtime paths combine capath with other options through the uniform
+/// option block. Returns `-1` for an unreadable or empty directory.
///
/// # Safety
///
@@ -314,12 +705,8 @@ pub unsafe extern "C" fn elephc_tls_connect_capath(
tls_connect_inner(host_ptr, host_len, port, config)
}
-/// Variant of `elephc_tls_connect` that authenticates the peer against the
-/// built-in webpki-roots but verifies the certificate for the host named by
-/// the `ssl.peer_name` stream-context option instead of the connection host.
-/// Used when a program connects to one address (e.g. an IP or alternate name)
-/// but the certificate is issued for a different hostname. `peer_name` is also
-/// sent as the SNI server name. Returns a handle ID, or `-1` on failure.
+/// Historical peer-name-only connect wrapper retained for ABI compatibility.
+/// New runtime paths carry the SNI/name override in the uniform option block.
///
/// # Safety
///
@@ -356,33 +743,15 @@ pub unsafe extern "C" fn elephc_tls_connect_peer_name(
/// `None` (the `ssl.passphrase` option cannot decrypt it in the rustls subset).
/// Not cached: client-cert connects are rare and each may use a distinct key.
fn client_cert_config(cert_path: &str, key_path: &str) -> Option> {
- let _ = rustls::crypto::ring::default_provider().install_default();
- let cert_pem = std::fs::read(cert_path).ok()?;
- let mut cert_reader: &[u8] = &cert_pem;
- let certs: Vec> = rustls_pemfile::certs(&mut cert_reader)
- .filter_map(|c| c.ok())
- .collect();
- if certs.is_empty() {
- return None;
- }
- let key_pem = std::fs::read(key_path).ok()?;
- let mut key_reader: &[u8] = &key_pem;
- let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_reader).ok().flatten()?;
- let mut roots = RootCertStore::empty();
- roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
- ClientConfig::builder()
- .with_root_certificates(roots)
- .with_client_auth_cert(certs, key)
- .ok()
- .map(Arc::new)
+ let mut options = TlsOptions::secure_defaults();
+ options.client_cert = Some(cert_path);
+ options.client_key = Some(key_path);
+ policy_client_config(options)
}
-/// Variant of `elephc_tls_connect` that presents a client certificate (mutual
-/// TLS) loaded from the `ssl.local_cert` / `ssl.local_pk` PEM files. Returns an
-/// integer handle ID, or `-1` on any failure (including an unreadable/malformed
-/// cert or key). The non-client-cert connect variants ignore the trailing
-/// `cert_*`/`key_*` arguments, so the runtime can share one call site and just
-/// select the function pointer.
+/// Historical explicit-client-key connect wrapper retained for ABI
+/// compatibility. New runtime paths combine client identity and trust/policy
+/// options through `elephc_tls_connect_with_options`.
///
/// # Safety
///
@@ -408,12 +777,170 @@ pub unsafe extern "C" fn elephc_tls_connect_client_cert(
tls_connect_inner(host_ptr, host_len, port, config)
}
-/// Variant of `elephc_tls_attach_fd` that presents a client certificate (mutual
-/// TLS) loaded from the `ssl.local_cert` / `ssl.local_pk` PEM files when
-/// promoting an already-connected TCP fd to TLS. Returns a handle ID, or `-1`
-/// on dup / handshake / SNI / cert-load failure. Used by
-/// `stream_socket_enable_crypto` when the active stream context carries a
-/// `local_cert`.
+/// Duplicates the caller's live TCP socket `fd` into a `TcpStream` that owns
+/// an independent reference, so the caller's original fd remains valid for
+/// its own I/O/close while the returned stream is used exclusively for TLS
+/// framing. Returns `None` if the descriptor is not an IPv4/IPv6 stream
+/// socket or if duplication fails. Shared by `elephc_tls_attach_fd` and
+/// `elephc_tls_attach_fd_client_cert`.
+///
+/// # Safety
+///
+/// `fd` must refer to a connected TCP socket owned by the caller.
+#[cfg(unix)]
+unsafe fn dup_as_tcp_stream(fd: i64) -> Option {
+ let fd = i32::try_from(fd).ok()?;
+ // STARTTLS can be requested on any PHP stream (including php://memory and
+ // Unix-domain sockets), so validate the type and family before constructing
+ // Rust's TCP-specific owning wrapper.
+ let mut socket_type: libc::c_int = 0;
+ let mut socket_type_len = std::mem::size_of::() as libc::socklen_t;
+ if libc::getsockopt(
+ fd,
+ libc::SOL_SOCKET,
+ libc::SO_TYPE,
+ &mut socket_type as *mut libc::c_int as *mut libc::c_void,
+ &mut socket_type_len,
+ ) != 0
+ || socket_type != libc::SOCK_STREAM
+ {
+ return None;
+ }
+ let mut socket_address: libc::sockaddr_storage = std::mem::zeroed();
+ let mut socket_address_len =
+ std::mem::size_of::() as libc::socklen_t;
+ if libc::getsockname(
+ fd,
+ &mut socket_address as *mut libc::sockaddr_storage as *mut libc::sockaddr,
+ &mut socket_address_len,
+ ) != 0
+ || !matches!(
+ socket_address.ss_family as libc::c_int,
+ libc::AF_INET | libc::AF_INET6
+ )
+ {
+ return None;
+ }
+ let dup_fd = libc::dup(fd);
+ if dup_fd < 0 {
+ return None;
+ }
+ Some(TcpStream::from_raw_fd(dup_fd))
+}
+
+/// Windows counterpart of the Unix `dup_as_tcp_stream` above. The incoming
+/// value is a raw 64-bit Winsock `SOCKET`, not a CRT file descriptor. A
+/// `ManuallyDrop` provides a temporary borrowed view solely so
+/// `try_clone()` can ask the standard library to duplicate the socket. The
+/// borrowed view never closes the caller's socket; the returned clone is the
+/// independently owned socket used and eventually closed by the TLS session.
+/// Winsock validates the socket type and IP family before Rust constructs the
+/// TCP-specific borrowed view. Non-socket, stale, and datagram handles return
+/// `None`.
+///
+/// # Safety
+///
+/// `fd` must be a live Winsock `SOCKET` represented without truncation in an
+/// `i64`. Ownership remains with the caller.
+#[cfg(windows)]
+unsafe fn dup_as_tcp_stream(fd: i64) -> Option {
+ if fd < 0 {
+ return None;
+ }
+ let socket = fd as RawSocket;
+ let winsock_socket = socket as libc::SOCKET;
+ let mut socket_type: libc::c_int = 0;
+ let mut socket_type_len = std::mem::size_of::() as libc::c_int;
+ if libc::getsockopt(
+ winsock_socket,
+ WINDOWS_SOL_SOCKET,
+ WINDOWS_SO_TYPE,
+ &mut socket_type as *mut libc::c_int as *mut libc::c_char,
+ &mut socket_type_len,
+ ) != 0
+ || socket_type != WINDOWS_SOCK_STREAM
+ {
+ return None;
+ }
+
+ // Winsock's SOCKADDR_STORAGE is 128 bytes and aligned to 64 bits. Use an
+ // equivalently sized/aligned buffer so both IPv4 and IPv6 addresses fit.
+ let mut socket_address = [0_u64; 16];
+ let mut socket_address_len = std::mem::size_of_val(&socket_address) as libc::c_int;
+ if libc::getsockname(
+ winsock_socket,
+ socket_address.as_mut_ptr().cast::(),
+ &mut socket_address_len,
+ ) != 0
+ {
+ return None;
+ }
+ let family = *(socket_address.as_ptr().cast::());
+ if !matches!(family, WINDOWS_AF_INET | WINDOWS_AF_INET6) {
+ return None;
+ }
+
+ let borrowed = ManuallyDrop::new(TcpStream::from_raw_socket(socket));
+ borrowed.try_clone().ok()
+}
+
+/// Attaches TLS to an existing TCP socket using the same combinable option tail
+/// as `elephc_tls_connect_with_options`.
+///
+/// # Safety
+///
+/// `fd` must refer to a live caller-owned TCP socket. `options_ptr` must point
+/// to a readable option block whose non-null spans remain valid for this call.
+/// When `crypto_method_present` is non-zero, `crypto_method` must be a supported
+/// client method mask containing TLS 1.2 and/or TLS 1.3. A positive
+/// `session_handle` may identify a live source TLS stream whose client context
+/// and resumption cache should be reused; unknown handles fall back to a fresh
+/// policy configuration.
+#[no_mangle]
+pub unsafe extern "C" fn elephc_tls_attach_fd_with_options(
+ fd: i64,
+ options_ptr: *const ElephcTlsClientOptions,
+ crypto_method: i64,
+ crypto_method_present: i64,
+ session_handle: i64,
+) -> i64 {
+ if crypto_method_present == 0 {
+ return -1;
+ }
+ let Some((options, peer_name)) = tls_options_from_abi(options_ptr) else {
+ return -1;
+ };
+ let Some(policy) = VerificationPolicy::from_flags(options.verification_flags) else {
+ return -1;
+ };
+ if peer_name.is_none() && policy.verify_peer_name {
+ return -1;
+ }
+ if protocol_versions_for_crypto_method(crypto_method).is_none() {
+ return -1;
+ }
+ let mut config = match session_client_config(session_handle) {
+ Some(config) => config,
+ None => {
+ let Some(config) = policy_client_config_for_method(options, crypto_method) else {
+ return -1;
+ };
+ config
+ }
+ };
+ let peer_name = match peer_name {
+ Some(peer_name) => peer_name,
+ None => {
+ Arc::make_mut(&mut config).enable_sni = false;
+ "localhost"
+ }
+ };
+ tls_attach_fd_inner(fd, peer_name, config)
+}
+
+/// Historical explicit-client-key attach wrapper retained for ABI
+/// compatibility. New runtime paths pass the complete option block through
+/// `elephc_tls_attach_fd_with_options`.
///
/// # Safety
///
@@ -422,7 +949,7 @@ pub unsafe extern "C" fn elephc_tls_connect_client_cert(
/// of valid UTF-8 bytes for the duration of this call.
#[no_mangle]
pub unsafe extern "C" fn elephc_tls_attach_fd_client_cert(
- fd: i32,
+ fd: i64,
peer_name_ptr: *const u8,
peer_name_len: usize,
cert_ptr: *const u8,
@@ -430,9 +957,6 @@ pub unsafe extern "C" fn elephc_tls_attach_fd_client_cert(
key_ptr: *const u8,
key_len: usize,
) -> i64 {
- if fd < 0 || peer_name_ptr.is_null() || peer_name_len == 0 {
- return -1;
- }
let Some((cert_path, key_path)) = client_cert_paths(cert_ptr, cert_len, key_ptr, key_len) else {
return -1;
};
@@ -440,30 +964,10 @@ pub unsafe extern "C" fn elephc_tls_attach_fd_client_cert(
Some(c) => c,
None => return -1,
};
- let host_bytes = std::slice::from_raw_parts(peer_name_ptr, peer_name_len);
- let host = match std::str::from_utf8(host_bytes) {
- Ok(s) => s,
- Err(_) => return -1,
- };
- let server_name: ServerName<'static> = match ServerName::try_from(host.to_string()) {
- Ok(name) => name,
- Err(_) => return -1,
- };
- let dup_fd = libc::dup(fd);
- if dup_fd < 0 {
+ let Some(Some(peer_name)) = optional_utf8(peer_name_ptr, peer_name_len) else {
return -1;
- }
- let sock = TcpStream::from_raw_fd(dup_fd);
- let conn = match ClientConnection::new(config, server_name) {
- Ok(c) => c,
- Err(_) => return -1,
};
- let id = next_handle_id();
- handles()
- .lock()
- .unwrap()
- .insert(id, Box::new(HandleEntry { sock, conn }));
- id
+ tls_attach_fd_inner(fd, peer_name, config)
}
/// Validates and decodes the client-cert/key pointer pair into `&str` paths.
@@ -487,6 +991,85 @@ unsafe fn client_cert_paths<'a>(
Some((cert_path, key_path))
}
+/// Decodes one optional UTF-8 C-ABI byte span. Zero length means absent; a
+/// nonzero length requires a non-null pointer and valid UTF-8.
+///
+/// # Safety
+///
+/// A non-null `ptr` must reference `len` readable bytes for this call.
+unsafe fn optional_utf8<'a>(ptr: *const u8, len: usize) -> Option