Repository navigation
Conversation
[Medium impact] The PR adds two builtins used by PHP callers across compiled and eval code. Fix numeric-string length bounds and allow string-convertible objects before merging.
|
Both functions were undefined. They are now registry builtins with a shared contract, native runtime helpers on every target and eval implementations, so eval(), get_defined_functions() and ReflectionFunction see them as internal functions. - `version_compare()` mirrors php-src's php_version_compare: C-string semantics, canonicalization unless the version starts with `#` (separators and digit/letter boundaries become one `.`, trailing separators dropped), saturating numeric tokens, prefix-matched special forms (dev < alpha/a < beta/b < RC/rc < # and numbers < pl/p), and the remaining tokens of the longer version compared against `#`. With an operator it answers the bool predicate; an unknown operator raises PHP's ValueError. The result is int without an operator, bool with a string operator, and a boxed int|bool when the operator is only known at run time (callable wrappers included). - `random_bytes()` reads the OS CSPRNG (arc4random_buf on macOS, getrandom on Linux with short-read and EINTR handling), never a weak fallback. A length below 1 raises PHP's ValueError; a refused getrandom throws an Exception with php-src's message (Random\RandomException is not declared yet). Closes illegalstudio#862
The version_compare check hook chose a result type without checking its arguments, so an array (also one passed through a bare array parameter) was lowered to the string "Array" and compared. Reject non-string versions and a non-nullable-string operator with PHP's TypeError wording, and give random_bytes a hook that rejects a container length.
On Linux a getrandom failure other than EINTR returned without freeing the result buffer, so every caught-and-retried call leaked it. Release the buffer on both targets before reporting the failure, and label the helper's instruction groups.
…enerate builtin docs
b80ae17 to
19c68c1
Compare
| let accepted = !inferred.is_php_array() && match inferred.codegen_repr() { | ||
| PhpType::Str | ||
| | PhpType::Int | ||
| | PhpType::Float | ||
| | PhpType::Bool | ||
| | PhpType::False | ||
| | PhpType::Mixed | ||
| | PhpType::TaggedScalar | ||
| | PhpType::Union(_) => true, | ||
| PhpType::Void | PhpType::Never => nullable, | ||
| _ => false, |
There was a problem hiding this comment.
String-convertible objects are rejected
check_string_parameter() rejects every object, including objects with __toString(). In weak mode, PHP accepts those objects for both versions and the operator. A class whose __toString() returns "1.2" therefore cannot be passed to version_compare() here. Allow string-convertible objects without allowing ordinary objects.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/builtins/string/version_compare.rs
Line: 63-73
Comment:
**String-convertible objects are rejected**
`check_string_parameter()` rejects every object, including objects with `__toString()`. In weak mode, PHP accepts those objects for both versions and the operator. A class whose `__toString()` returns `"1.2"` therefore cannot be passed to `version_compare()` here. Allow string-convertible objects without allowing ordinary objects.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
PHP does accept a __toString() object for string parameters in weak mode. In elephc this is not specific to version_compare: on main, strlen(new V()) (where V::__toString() returns "1.2") is rejected at compile time with strlen() argument must be string. Only strip_tags's allow-list has the weak-Stringable lowering (BuiltinArgumentLowering::WeakStringUnion), and that lowering covers a single argument and marks the builtin's result as an owned string, so it cannot be reused here as is. The rejection is a compile-time error, never a wrong runtime result. Supporting Stringable objects across the string builtins belongs in a separate change to the shared argument lowering, not in this PR.
There was a problem hiding this comment.
That distinction is valid. Since Stringable-object coercion is already a shared limitation of the string-builtin argument lowering—and this change does not introduce it—it is out of scope for version_compare() and does not represent a wrong runtime result in this PR. I’m withdrawing this finding; support for weak-mode __toString() objects should be handled in a separate shared-lowering change.
PHP's coercive mode still converts null passed to a scalar builtin parameter (deprecated since 8.1, not an error): version_compare(null, "1") compares the empty string and returns -1, and random_bytes(null) reads a zero length and throws the catchable ValueError. The argument checks rejected null at compile time; accept it like the other scalar shapes. A nullable int length also reached the random_bytes lowering as a tagged scalar, which the shared range/length loader did not handle; read its null as 0 there.
…s PHP Without a php code fence the generated pages joined the example lines into one Markdown paragraph. Regenerate the builtin docs and registry.
…result pair On x86_64 the second version was moved from the string result pair (rax, rdx) straight into rdx/rcx. For any version that is not already a string (null, int, float, bool) the pointer overwrote rdx before the length was copied out of it, so the helper read a pointer-sized length: version_compare(null, null, "eq") returned false on linux-x86_64. Load each version into the result pair and park it on the stack, as the lowering already reloads both from there before the helper call.
The checker accepted a string $length, but the length loader had no
string branch, so random_bytes("16") failed during code generation.
Convert it the way PHP's weak-mode int parameter does: a numeric string
(PHP whitespace around it, a float spelling truncated) is the length,
and any other string, including a leading-numeric one such as "3abc",
raises php-src's TypeError. The eval interpreter now applies the same
rule instead of reading a non-numeric string as 0.
…are operator A nullable scalar operator (?int, ?bool) is a tagged scalar, which the lowering treated as always present: a null value became the empty operator string and threw ValueError instead of returning the int comparison. Branch on the null tag at run time, and type such a call as int|bool like a boxed operator.
| ctx.load_value_to_result(length)?; | ||
| let invalid = ctx.next_label("random_bytes_length_not_numeric"); | ||
| let ready = ctx.next_label("random_bytes_length_ready"); | ||
| super::super::super::enums::emit_string_result_to_int_checked(ctx, &invalid); |
There was a problem hiding this comment.
Invalid lengths reach allocation
The new string conversion checks numeric spelling but not whether the value fits an integer parameter. random_bytes("9223372036854775808") passes the compiled check and becomes PHP_INT_MAX, reaching allocation instead of throwing PHP’s catchable TypeError. The eval parser also turns "1e400" into i64::MAX. A program expecting to catch an invalid length can instead stop running after an enormous allocation attempt.
Reject out-of-range values before converting or allocating, while preserving exact integer strings. AGENTS.md requires PHP builtins to behave like PHP.
Context Used: AGENTS.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/codegen/lower_inst/builtins/math/random.rs
Line: 119
Comment:
**Invalid lengths reach allocation**
The new string conversion checks numeric spelling but not whether the value fits an integer parameter. `random_bytes("9223372036854775808")` passes the compiled check and becomes `PHP_INT_MAX`, reaching allocation instead of throwing PHP’s catchable `TypeError`. The eval parser also turns `"1e400"` into `i64::MAX`. A program expecting to catch an invalid length can instead stop running after an enormous allocation attempt.
Reject out-of-range values before converting or allocating, while preserving exact integer strings. `AGENTS.md` requires PHP builtins to behave like PHP.
**Context Used:** AGENTS.md ([source](https://github.com/illegalstudio/elephc/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary
Second family of the missing-builtins PR of the #1886 split. Both functions were undefined. They are now real builtins:
get_defined_functions()['internal']lists them andisInternal()returns true;builtin!home;version_compare()mirrors php-src'sphp_version_compare:#:-,_,+and other non-alphanumerics become one., digit/letter boundaries are split, and trailing separators are dropped.strtol. Other tokens compare by special-form order, prefix-matched: unknown <dev<alpha/a<beta/b<RC/rc<#and numbers <pl/p.#form.<,lt,<=,le,>,gt,>=,ge,==,=,eq,!=,<>andne. They return a bool. Any other operator raises php-src'sValueError.intwithout an operator (or withnull),boolwith a string operator, andint|bool(boxed) when the operator is only known at run time, including string callables and first-class callables.There is a single implementation per backend. The comparator used by #1886's dynamic-include manifest (
resolver/dynamic_includes/version_fold.rs) is not on main and is not added here. If that design PR goes ahead, it should reuse this builtin rather than keep a second copy.random_bytes()reads the operating system's CSPRNG:arc4random_bufon macOS, and thegetrandomsyscall on Linux, which loops over short reads and retriesEINTR. There is no weak fallback. A$lengthbelow 1 raises php-src'sValueError. If the kernel refusesgetrandom, anExceptionis thrown with php-src's message, becauseRandom\RandomExceptionis not declared yet. Eval reads/dev/urandom. Therandom_u32change from #1886 (aborting instead of thetime()fallback) is not part of this PR.Closes #862
Part of #859 (
version_compare)Not in this PR
get_debug_type(). Its object case needs php'sParent@anonymous/Interface@anonymousnames for anonymous classes, which means a per-class debug-name table in the runtime class metadata. It will come in its own PR.pack()/unpack()(Implement pack and unpack binary conversion builtins #863). They are a large format-driven pair and will come in their own PR.Tests
On main df57a98 every new test fails with
Undefined function. On this branch they pass:codegen::strings::version_compare::{test_version_compare_matches_php_table, test_version_compare_operators, test_version_compare_is_an_internal_function}codegen::math::random_bytes::{test_random_bytes_lengths_and_distinct_values, test_random_bytes_rejects_non_positive_length, test_random_bytes_results_do_not_leak}error_tests::misc::string_and_type_builtins::test_error_{version_compare,random_bytes}_wrong_argsThe contract-count gates were updated (+2). Builtin docs and registries were regenerated. The x86_64 helpers were assembled for both Linux targets, but only run on macOS AArch64; CI covers execution on Linux.
This PR and the string-builtins PR (
strspn,strcspn,strrchr,substr_compare,preg_quote) are independent, each based on main, but both bump the same contract counts and lists (registry.rs/support.rs,runtime_fn.rs, the Magician hooks) and regenerate the same docs. Whichever lands second needs a rebase and a fresh docs regeneration; I will do it.Refs #1886
AI assistance
This PR was prepared with substantial help from Claude Code (Anthropic). The
version_comparebehavior was first modeled in Rust, checked against PHP 8.5, and then implemented in the runtime helpers and in eval.🤖 Generated with Claude Code