Skip to content

Commit 9072a51

Browse files
Match unserialize() permissiveness on scoped-mode property names
In scoped mode, the pre-v0.4.0 hot-path validation rejected any property name that wasn't a string, contained a NUL byte, or looked like a mangled key, with a ValueError. That was stricter than what `unserialize()` does when it encounters the same shapes in an `O:…` payload: - Integer keys: `unserialize()` coerces to string on dynamic property access (PHP's engine rule). The ext now iterates via ZEND_HASH_FOREACH_KEY_VAL and synthesises the string via `zend_long_to_str`. - NUL-in-middle names: `unserialize()` stores them as-is on the dynamic property table. The ext drops the upfront `memchr` check on the stdClass write path and the dynamic-fallback write path, letting the engine store the raw name. - NUL-prefix names: the engine native `Error` ("Cannot access property starting with \0") already surfaces from `zend_std_write_property` / `zend_hash_update`. No need for a pre-check. DEEPCLONE_HYDRATE_MANGLED_VARS mode is unchanged — it still parses and validates mangled keys as before (that's the entire point of the flag). Besides the semantic alignment, this saves a hot-path validation per written property — ~18 ns per prop in the polyfill, cheap in the ext but the polyfill side is the real win.
1 parent 4f47a1b commit 9072a51

3 files changed

Lines changed: 43 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
with `DEEPCLONE_HYDRATE_MANGLED_VARS`, `DEEPCLONE_HYDRATE_CALL_HOOKS`, and
2727
`DEEPCLONE_HYDRATE_NO_LAZY_INIT`.
2828

29+
### Changed
30+
31+
- `deepclone_hydrate()` scoped-mode property-name validation now matches
32+
`unserialize()` permissiveness: integer keys coerce to strings on dynamic
33+
property access; NUL-in-middle names are stored as raw dynamic properties
34+
(same as `unserialize()` on an `O:…` payload with a NUL-containing key);
35+
NUL-prefix names surface the engine's native `Error: Cannot access property
36+
starting with "\0"`. The pre-v0.4.0 `ValueError` was stricter than
37+
`unserialize()` and cost a per-prop validation in the hot path; dropping it
38+
aligns the semantics and saves hot-path work. `DEEPCLONE_HYDRATE_MANGLED_VARS`
39+
mode still parses and validates mangled keys.
40+
2941
## [0.3.1] - 2026-04-15
3042

3143
### Fixed

deepclone.c

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3218,16 +3218,16 @@ PHP_FUNCTION(deepclone_hydrate)
32183218
EG(fake_scope) = scope_ce;
32193219
}
32203220

3221+
zend_ulong prop_idx;
32213222
zend_string *prop_name;
32223223
zval *prop_val;
3223-
ZEND_HASH_FOREACH_STR_KEY_VAL(Z_ARRVAL_P(scope_props), prop_name, prop_val) {
3224-
if (UNEXPECTED(!prop_name)) {
3225-
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) scope \"%s\" must have only string keys",
3226-
ZSTR_VAL(scope_name));
3227-
EG(fake_scope) = old_scope;
3228-
if (scoped_owned) zval_ptr_dtor(&local_scoped);
3229-
zval_ptr_dtor(&obj_zval);
3230-
RETURN_THROWS();
3224+
ZEND_HASH_FOREACH_KEY_VAL(Z_ARRVAL_P(scope_props), prop_idx, prop_name, prop_val) {
3225+
/* Integer keys: coerce to string on dynamic property access, matching
3226+
* unserialize()'s permissiveness. Allocated name is released below. */
3227+
bool prop_name_owned = false;
3228+
if (!prop_name) {
3229+
prop_name = zend_long_to_str((zend_long) prop_idx);
3230+
prop_name_owned = true;
32313231
}
32323232

32333233
/* "\0" key = internal state for ArrayObject/ArrayIterator/SplObjectStorage */
@@ -3292,15 +3292,9 @@ PHP_FUNCTION(deepclone_hydrate)
32923292
}
32933293

32943294
if (obj_ce == zend_standard_class_def) {
3295-
if (UNEXPECTED(memchr(ZSTR_VAL(prop_name), '\0', ZSTR_LEN(prop_name)) != NULL)) {
3296-
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) scope \"%s\" contains an invalid property name; "
3297-
"use bare property names in scoped mode, or pass DEEPCLONE_HYDRATE_MANGLED_VARS in $flags",
3298-
ZSTR_VAL(scope_name));
3299-
EG(fake_scope) = old_scope;
3300-
if (scoped_owned) zval_ptr_dtor(&local_scoped);
3301-
zval_ptr_dtor(&obj_zval);
3302-
RETURN_THROWS();
3303-
}
3295+
/* Matches unserialize(): NUL-in-middle names are stored as-is,
3296+
* NUL-prefix names are rejected by the engine on read. No
3297+
* pre-validation — the polyfill follows the same rule. */
33043298
Z_TRY_ADDREF_P(prop_val);
33053299
zend_hash_update(obj->properties, prop_name, prop_val);
33063300
} else {
@@ -3317,31 +3311,27 @@ PHP_FUNCTION(deepclone_hydrate)
33173311
if (dc_is_backed_declared_property(pi)) {
33183312
bool ok = dc_write_backed_property(obj, pi, prop_name, prop_val, flags);
33193313
if (UNEXPECTED(!ok)) {
3314+
if (prop_name_owned) zend_string_release(prop_name);
33203315
EG(fake_scope) = old_scope;
33213316
if (scoped_owned) zval_ptr_dtor(&local_scoped);
33223317
zval_ptr_dtor(&obj_zval);
33233318
RETURN_THROWS();
33243319
}
33253320
} else {
3326-
/* Fallback: dynamic property or unknown name — validate first */
3327-
if (UNEXPECTED(memchr(ZSTR_VAL(prop_name), '\0', ZSTR_LEN(prop_name)) != NULL)) {
3328-
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) scope \"%s\" contains an invalid property name; "
3329-
"use bare property names in scoped mode, or pass DEEPCLONE_HYDRATE_MANGLED_VARS in $flags",
3330-
ZSTR_VAL(scope_name));
3331-
EG(fake_scope) = old_scope;
3332-
if (scoped_owned) zval_ptr_dtor(&local_scoped);
3333-
zval_ptr_dtor(&obj_zval);
3334-
return;
3335-
}
3321+
/* Fallback: dynamic property or unknown name. Matches
3322+
* unserialize(): the engine rejects NUL-prefix names with
3323+
* \Error and accepts NUL-in-middle as raw dynamic props. */
33363324
zend_std_write_property(obj, prop_name, prop_val, NULL);
33373325
if (UNEXPECTED(EG(exception))) {
3326+
if (prop_name_owned) zend_string_release(prop_name);
33383327
EG(fake_scope) = old_scope;
33393328
if (scoped_owned) zval_ptr_dtor(&local_scoped);
33403329
zval_ptr_dtor(&obj_zval);
33413330
RETURN_THROWS();
33423331
}
33433332
}
33443333
}
3334+
if (prop_name_owned) zend_string_release(prop_name);
33453335
} ZEND_HASH_FOREACH_END();
33463336

33473337
EG(fake_scope) = old_scope;

tests/deepclone_hydrate.phpt

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -268,32 +268,30 @@ try {
268268
var_dump(str_contains($e->getMessage(), 'array values'));
269269
}
270270

271-
// NUL byte in property name inside scoped array → ValueError
272-
try {
273-
deepclone_hydrate('stdClass', ['stdClass' => ["foo\0bar" => 'val']]);
274-
} catch (\ValueError $e) {
275-
var_dump(str_contains($e->getMessage(), 'invalid property name'));
276-
}
271+
// NUL-in-middle of a property name: matches unserialize() — accepted
272+
// as a dynamic property, the engine stores the raw name.
273+
$o = deepclone_hydrate('stdClass', ['stdClass' => ["foo\0bar" => 'val']]);
274+
var_dump(((array) $o)["foo\0bar"] === 'val');
277275

278-
// NUL byte in mangled key property portion → ValueError
276+
// NUL byte in mangled key property portion → ValueError (MANGLED_VARS mode parses keys)
279277
try {
280278
deepclone_hydrate('stdClass', ["\0*\0foo\0bar" => 'val'], DEEPCLONE_HYDRATE_MANGLED_VARS);
281279
} catch (\ValueError $e) {
282280
var_dump(str_contains($e->getMessage(), 'invalid mangled key'));
283281
}
284282

285-
// Integer key inside scoped array → ValueError
286-
try {
287-
deepclone_hydrate('stdClass', ['stdClass' => [0 => 'val']]);
288-
} catch (\ValueError $e) {
289-
var_dump(str_contains($e->getMessage(), 'string keys'));
290-
}
283+
// Integer key inside a scope: matches unserialize() — coerced to string
284+
// on dynamic property access.
285+
$o = deepclone_hydrate('stdClass', ['stdClass' => [0 => 'val']]);
286+
var_dump($o->{'0'} === 'val');
291287

292-
// Mangled key inside scoped array → ValueError
288+
// Mangled-shape key inside a scope: engine rejects the NUL-prefix
289+
// property name with \Error. Callers wanting mangled keys pass
290+
// DEEPCLONE_HYDRATE_MANGLED_VARS.
293291
try {
294292
deepclone_hydrate('stdClass', ['stdClass' => ["\0stdClass\0x" => 'val']]);
295-
} catch (\ValueError $e) {
296-
var_dump(str_contains($e->getMessage(), 'DEEPCLONE_HYDRATE_MANGLED_VARS'));
293+
} catch (\Error $e) {
294+
var_dump(str_contains($e->getMessage(), 'starting with'));
297295
}
298296

299297
// Interface as scope → ValueError
@@ -382,5 +380,4 @@ bool(true)
382380
bool(true)
383381
bool(true)
384382
bool(true)
385-
bool(true)
386383
Done

0 commit comments

Comments
 (0)