Skip to content

feat(builtins): add version_compare and random_bytes - #1925

Open
JGamboa wants to merge 12 commits into
illegalstudio:mainfrom
JGamboa:feat/misc-builtins
Open

JGamboa wants to merge 12 commits into
illegalstudio:mainfrom
JGamboa:feat/misc-builtins

Conversation

@JGamboa

@JGamboa JGamboa commented Oct 19, 2026

Copy link
Copy Markdown

Summary

Second family of the missing-builtins PR of the #1886 split. Both functions were undefined. They are now real builtins:

  • a shared contract, so they get generated docs, get_defined_functions()['internal'] lists them and isInternal() returns true;
  • a builtin! home;
  • native runtime helpers on AArch64 and Linux x86_64;
  • Magician eval implementations.

version_compare() mirrors php-src's php_version_compare:

  • C-string semantics (it stops at NUL), and an empty version orders below any other.
  • A version is canonicalized unless it starts with #: -, _, + and other non-alphanumerics become one ., digit/letter boundaries are split, and trailing separators are dropped.
  • Numeric tokens compare with a saturating strtol. Other tokens compare by special-form order, prefix-matched: unknown < dev < alpha/a < beta/b < RC/rc < # and numbers < pl/p.
  • The rest of the longer version is compared against the # form.
  • The operators are <, lt, <=, le, >, gt, >=, ge, ==, =, eq, !=, <> and ne. They return a bool. Any other operator raises php-src's ValueError.
  • The result type follows the call: int without an operator (or with null), bool with a string operator, and int|bool (boxed) when the operator is only known at run time, including string callables and first-class callables.
  • The algorithm was checked against PHP 8.5 on 58 version pairs.

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_buf on macOS, and the getrandom syscall on Linux, which loops over short reads and retries EINTR. There is no weak fallback. A $length below 1 raises php-src's ValueError. If the kernel refuses getrandom, an Exception is thrown with php-src's message, because Random\RandomException is not declared yet. Eval reads /dev/urandom. The random_u32 change from #1886 (aborting instead of the time() 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's Parent@anonymous / Interface@anonymous names 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_args

The 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_compare behavior was first modeled in Rust, checked against PHP 8.5, and then implemented in the runtime helpers and in eval.

🤖 Generated with Claude Code

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities. labels Oct 19, 2026
@greptile-apps

greptile-apps Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 3/5

[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.

Fix All in Claude CodeFindings

  1. P1 Invalid lengths reach allocation ▶
  2. P1 String-convertible objects are rejected ▶
Fix with agent prompt
### Issue 1
src/codegen/lower_inst/builtins/math/random.rs:119
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.

### Issue 2
src/builtins/string/version_compare.rs:67-78
`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.

Summary

Adds version_compare() and random_bytes() to compiled programs and eval.

  • PHP callers can compare release versions with version_compare().
  • PHP callers can get a string of secure random bytes with random_bytes().

Reviews (4) · Last reviewed commit: "fix(codegen): keep the int form for a nu..." · Reviewed by Greptile

Comment thread src/builtins/string/version_compare.rs
Comment thread src/codegen_support/runtime/arrays/random_bytes.rs
Comment thread crates/elephc-builtin-contract/src/catalog_data.rs
Comment thread tests/error_tests/misc/string_and_type_builtins.rs
Comment thread src/codegen_support/runtime/arrays/random_bytes.rs
Comment thread src/builtins/string/version_compare.rs Outdated
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.
@JGamboa
JGamboa force-pushed the feat/misc-builtins branch from b80ae17 to 19c68c1 Compare October 21, 2026 06:04
Comment thread src/builtins/math/random_bytes.rs
Comment on lines +63 to +73
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code Fix in Codex Fix in Cursor

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/codegen/lower_inst/builtins/strings/version.rs
Comment thread tests/codegen/math/random_bytes.rs
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Fix in Claude Code Fix in Codex Fix in Cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:builtins Touches PHP builtin declarations or emitters. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement cryptographically secure random_bytes

1 participant