fix!: Soundness of Zend API - #776
Conversation
Coverage Report for CI Build 34054483238Coverage increased (+0.04%) to 68.667%Details
Uncovered Changes
Coverage Regressions6 previously-covered lines in 5 files lost coverage.
Coverage Stats
💛 - Coveralls |
|
@upsun-dispatch review |
Merging this PR will not alter performance
Performance Changes
Comparing |
There was a problem hiding this comment.
Note
Reviewed — No blocking findings · 🔵 3 minor points
🔍 Full review · 33 files reviewed
🔵 Minor points
src/zend/handlers.rs:72—get_gcreplaces the engine handler with a hand-written copy ofzend_std_get_gc's standard-object branch only. Two consequences. (1) It is version-independent Rust standing in for an engine function that has changed across 8.1-8.5: on PHP 8.4+ a Rust-backed class can be made lazy (ZendObject::make_lazy/zend_object_make_lazyis wired up andis_lazy_proxyis tested), and a lazy object keeps its initializer closure (and, for a proxy, the real instance) in slots allocated pastce->default_properties_count; reporting onlydefault_properties_countentries hides them from the collector, so a cycle through a lazy Rust object's initializer is never collected. (2) Any zval owned by the Rust struct itself (a#[php(prop)] pub f: Zvalholding an object) is never reported, so those cycles leak silently. Neither case corrupts memory, but both are permanent leaks that the previous handler at least attempted to cover.src/zend/handlers.rs:305— The COW separation callszend_array_dupon the object's own property table. That table containsIS_INDIRECTentries pointing intoobj->properties_tablefor every declared property (all#[php(prop)]fields are declared throughzend_declare_property, and a Rust class extendingExceptioninheritsmessage/code/file/line/traceslots).zend_array_dupresolvesIS_INDIRECTwhen copying, so the table installed as(*object).propertiesis a snapshot detached from the slots: later engine writes to those declared properties go to the slot and are invisible to every laterget_propertiesconsumer (var_dump,(array),json_encode) and, becauseget_gcreturns that same table with*n = 0, invisible to the GC as well. The trigger isobj->propertieshaving refcount > 1 when the handler runs (a caller that shared the table rather than duplicating it, e.g. viazend_proptable_to_symtablefast path, then a second property fetch on the same object).src/zend/handlers.rs:299—let ht = &*props;dereferences the result ofzend_std_get_propertiesunconditionally. The previous code tolerated a null return (.as_mut().or_else(...)); the new code turns any null into an immediate null-reference deref inside anextern "C"handler. The SAFETY comment asserts non-null for a live object, but that is an assumption about engine internals across 8.1-8.5 rather than something checked here; a cheapif props.is_null() { return props; }keeps the guarantee local.
Verification
Zval::binary_slicechecks both the length multiple andval's alignment before the now-unsafePackSlice::unpack_into, and the new unit tests cover the 7-byte rejection.- The new
get_gcmatcheszend_std_get_gc's standard branch shape:*table=NULL,*n=0pluspropertieswhen set, elseproperties_tablewithce->default_properties_count. - Every
ZBox::into_rawcall site was converted to a raw-pointer deref ((*obj).std,(*raw).std,(*this).get_mut_zend_obj()), preserving the dec_count/set_object refcount balance. Pack::unpack_intonow reads withread_unaligned, so dropping its alignment claim while marking onlyPackSlice::unpack_intounsafe is consistent.- tests/sapi.rs keeps the
SapiModulelocal alive (shadowed, not moved) until aftercleanup_sapi_allocations, so removingBox::from_rawdoes not free the name strings early. - The mago pin is bumped identically in .github/workflows/build.yml and flake.nix (1.47.6).
The diff adds unit tests for Zval::binary_slice (partial element, packed u64), a new PHP integration fixture tests/src/integration/binary_slice/binary_slice.php wired into tests/src/lib.rs, and GC/refcount assertions in class/class.php; these run under the Build and Test, Test with embed and test-asan jobs in .github/workflows/build.yml, and the Lint job runs clippy pedantic plus mago lint. Nothing in the diff exercises the new get_properties copy-on-write branch (no test makes obj->properties shared) or get_gc with a lazy object.
Review details
- Commit: 5b089bc
- Model: claude-opus-5
Description
Why
Safe APIs let user code read past a string, keep a value the iterator already freed, hand the engine a pointer it misreads, or mutate a table the GC is walking. Nothing crashes on the happy path, so it lasted. Compiling without
unsafeshould mean something again.How
Every change checked against php-src 8.1 to 8.5.
gc_collect_cycles()trippedHT_ASSERT_RC1. Not a refcount leak on$thisreturns: the GC reaches ourget_propertiesthroughzend_std_get_gc, several times per cycle, while holding its own reference on the table we rewrite.sequenceDiagram participant GC as collector participant H as get_properties (ours) participant T as obj->properties GC->>H: pass 1 H->>T: merge Rust props (rc 1) GC->>T: hold ref (rc 2) GC->>H: pass 2 H->>T: merge on shared table Note over T: assert on debug, corruption on releaseFix: own
get_gcreturning the engine storage untouched, soget_propertiesonly runs for user-facing reads, where the engine duplicates before sharing.The rest makes signatures honest:
unsafe;Zval::binary_slicechecks length and alignment;Zval::set_ptrisunsafe, the engine readsIS_PTRas typed pointers;ZBox::into_rawreturns a pointer, not&'static mut;into_rawconstructors and a dead deprecated flag removed.Alignment guard: no measurable cost on
binary_slice_reads.Migration:
guide/src/migration-guides/v0.16.md.Checklist