feat(network): inet_pton()/inet_ntop() cover IPv6, the family they always claimed - #1056
Open
Guikingone wants to merge 3 commits into
Open
Guikingone wants to merge 3 commits into
Guikingone wants to merge 3 commits into
Conversation
|
…ways claimed
inet_pton('127.0.0.1') // PHP: 4 bytes elephc: 4 bytes
inet_pton('2001:4860:4860::8888') // PHP: 16 bytes elephc: false
The helper was a dotted-quad parser wearing the builtin's name: its own header said
"Parses a dotted-quad IPv4 string", and every IPv6 address came back `false`. A
program validating a proxy-provided client address rejected half the internet.
## The parsing is the platform's
`inet_pton(3)` and `inet_ntop(3)` are in libc on every target this compiler emits for,
and they are where PHP's own implementation goes. Reproducing them by hand would mean
reimplementing `::` compression, the embedded-IPv4 form, zone identifiers and the
canonical-spelling rules -- twice, once per architecture, and wrong in a different way
on each.
So the helpers materialize the arguments and call them. `__rt_inet_pton` copies its
borrowed bytes into a NUL-terminated stack buffer (the C parser needs one; PHP strings
are pointer+length) and parses straight into the concat buffer, so the caller's string
contract is unchanged. `__rt_inet_ntop` renders into a stack buffer and copies the
result out.
The family is chosen the way php-src chooses it: a `:` anywhere in the text selects
`AF_INET6`, and on the way back the packed LENGTH does -- 4 or 16, anything else
`false`.
## The IPv4 paths stay where they were
`__rt_inet_ntop` still renders a 4-byte address by packing the octets and tail-calling
`__rt_long2ip`, with no C call. That path was never wrong, and it is the common one.
## `AF_INET6` is the TARGET's
Darwin spells it 30 and Linux 10. Handing `inet_pton(3)` the wrong one makes it answer
`EAFNOSUPPORT` for every IPv6 address -- reported as `false` here -- so a
cross-compiled binary would reject addresses the same source accepts natively, and no
host-run test would see it. Both helpers carry emitter tests that pin the constant per
target, iOS and the iOS simulator included.
## Verified
Against host PHP 8.5.10, byte-identical across 30 shapes: the issue's own reproducer;
`::1`, `::`, the expanded and compressed spellings of the same address, `::ffff:`
mapped addresses, a zone identifier; the IPv4 family and its invalid inputs
(`256.0.0.1`, `1.2.3`, empty); IPv6 inputs that must stay invalid (`1:2:3:4:5:6:7:8:9`,
`gggg::1`); and round trips through both helpers, including the canonical spelling
`inet_ntop()` picks when several are possible.
Both helpers write into the shared concat buffer, so a fixture keeps three packed
addresses alive at once, puts calls inside a concatenation that is also writing there,
and runs 50 round trips in a loop. A mistracked cursor shows up as one address
overwriting another; all of it matches PHP.
The five new codegen tests were confirmed load-bearing by reverting both helpers: four
fail, and the fifth is the IPv4 guard, which is meant to pass either way.
`cargo test --test codegen_tests -- codegen::strings` passes in full (357), as do 1920
unit tests and 1542 error tests.
## One deliberate divergence
The copy into the NUL-terminated buffer bounds the input at 255 bytes. Reference PHP
has no bound -- it hands the whole string to `inet_pton(3)`, which ignores everything
past the `%` -- so a zone identifier longer than that is accepted there and refused
here. 255 is an order of magnitude past `IF_NAMESIZE`, so both answers are `false` for
anything that is actually an address. The boundary is pinned on both sides.
## Docs and example
`docs/php/strings.md` states both families, the spellings accepted, what rendering
canonicalizes, and the input bound.
`docs/internals/the-runtime.md` records which half is hand-written and which is libc,
how the family is chosen, and why `AF_INET6` is pinned per target.
`examples/ip-conversion/main.php` packs and renders IPv6 beside the IPv4 it already
had: the byte length that distinguishes the families, the canonical spelling, an
IPv4-mapped address, a zone identifier that packing discards, and the `false` cases.
Its output is verbatim host PHP 8.5.10.
Fixes #692
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
…m Linux-visible
Review found three, and the Linux CI failures are two of them. None reproduced on the
host, which is the shape of all three: an ARM64 macOS run cannot see any of them.
## The x86_64 IPv4 address never reached long2ip
The rewrite dropped `mov rdi, rax` before the tail jump, so `__rt_long2ip` read the
caller's packed-string POINTER as an IPv4 integer. `inet_ntop(inet_pton('8.8.8.8'))`
rendered an unrelated address on every x86_64 target, and the ARM64 path -- the only one
a host run exercises -- was correct throughout.
Restored, and pinned: the IPv4 emitter test now asserts the move as well as the jump,
so the argument cannot go missing again without a unit test failing on any host.
## Zone identifiers were a macOS promise sold as a portable one
`inet_pton(3)` takes `fe80::1%eth0` on Darwin and refuses it on glibc. Measured both
ways -- a C probe on this host returns 1 and the zone-free bytes; the reviewer's Linux
runs return false.
PHP has the same split, for the same reason: it delegates to libc too. So elephc's
behaviour was right and its CLAIM was wrong. The fixture, the docs and the example all
asserted the macOS answer as if it were the only one.
The portable fixtures no longer mention a zone identifier, and the docs now state the
divergence as the inherited libc behaviour it is. The bound fixture goes with it: the
only inputs longer than 255 bytes that any platform would otherwise parse carry a zone,
so the bound cannot be isolated portably. What replaces it pins the observable contract
instead -- long junk is `false`, and the refusal leaves nothing behind for the calls
after it.
## Both helpers wrote past the scratch buffer
They took their destination straight from `_concat_buf + _concat_off`, as the IPv4-only
version did for its four bytes. Sixteen bytes of packed address, or up to 45 bytes of
rendering, run off the end of the 64 KiB buffer once an earlier result has pushed the
cursor near it, and corrupt whatever follows.
Both now reserve through `__rt_concat_reserve` and publish through
`__rt_concat_publish`, which is the bounds-checked front end every other producer uses
and which falls back to the heap when the scratch no longer fits. `__rt_inet_ntop`
renders into its own stack buffer first and copies out, so the reservation is for the
exact rendered length rather than a worst case.
A unit test per helper asserts both calls are present AND that `_concat_off` is never
named directly, which is what would catch a future rewrite drifting back.
## Verified
Every earlier fixture still matches host PHP 8.5.10 byte for byte, minus the zone rows
that were never portable. 1922 unit tests pass, `codegen::strings` passes in full (357),
and the example's output is unchanged apart from the removed zone line.
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
…and cover the arguments
Two more from review, both real.
## The reservation could leak
`__rt_concat_reserve` answers with an OWNED heap block once the scratch buffer can no
longer fit the request. An address well-formed enough to be reserved for and then
refused by `inet_pton(3)` jumped straight to the false epilogue, leaking one allocation
per call in that state.
The parse-failure path now releases through `__rt_heap_free_safe` -- a no-op for a
scratch pointer, which is the ordinary case -- before reporting false. A separate label
for it, because the early refusals (empty, oversized) have reserved nothing yet and must
not free anything. Both architectures, pinned by a unit test per target.
`__rt_inet_ntop` needs none of this: it reserves AFTER the rendering has succeeded, so
there is no failure path between the reservation and the publish.
## The arguments were only half covered
AGENTS.md asks for error tests on argument counts and types when a builtin changes. The
zero-argument diagnostics existed; the rest did not.
* Arity from the other side: one argument is also the MAXIMUM, and a second one is
rejected rather than ignored. Worth pinning here specifically, because the family
gained an address family that is chosen from the argument's own text and length --
accepting a stray second argument is the natural way for that to go wrong.
* A coercible argument behaves like PHP's own coercion: `inet_pton(42)` asks about
`"42"`, which is no address either way, and both answer `false`.
* An argument with no string form is REFUSED by name. PHP raises
`TypeError: ... must be of type string, array given`; elephc declines the program with
`inet_pton string coercion for PHP type Array(Int)`, which is the shared behaviour of
every builtin taking a single string argument. Pinned so the divergence is legible
rather than discovered.
1923 unit tests, 1543 error tests and the seven inet codegen tests pass.
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone
force-pushed
the
fix/692-inet-pton-ipv6
branch
from
September 18, 2026 13:10
61ae9c2 to
41e1a38
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #692.
The helper was a dotted-quad parser wearing the builtin's name: its own header said
"Parses a dotted-quad IPv4 string", and every IPv6 address came back
false. A programvalidating a proxy-provided client address rejected half the internet.
The parsing is the platform's
inet_pton(3)andinet_ntop(3)are in libc on every target this compiler emits for, andthey are where PHP's own implementation goes. Reproducing them by hand would mean
reimplementing
::compression, the embedded-IPv4 form and the canonical-spelling rules —twice, once per architecture, and wrong in a different way on each.
So the helpers materialize the arguments and call them.
__rt_inet_ptoncopies its borrowedbytes into a NUL-terminated stack buffer (the C parser needs one; PHP strings are
pointer+length) and parses into reserved scratch.
__rt_inet_ntoprenders into a stackbuffer and copies the result out.
The family is chosen the way php-src chooses it: a
:anywhere in the text selectsAF_INET6, and on the way back the packed length does — 4 or 16, anything elsefalse.The IPv4 paths stay where they were
__rt_inet_ntopstill renders a 4-byte address by packing the octets and tail-calling__rt_long2ip, with no C call. That path was never wrong, and it is the common one.AF_INET6is the TARGET'sDarwin spells it 30 and Linux 10. Handing
inet_pton(3)the wrong one makes it answerEAFNOSUPPORTfor every IPv6 address — reported asfalsehere — so a cross-compiled binarywould reject addresses the same source accepts natively, and no host-run test would see it.
Both helpers carry emitter tests that pin the constant per target, iOS and the iOS simulator
included.
Three things the first cut got wrong, all Linux-visible
Review caught all three, and none of them reproduces on an ARM64 macOS host — which is the
shape of all three.
long2ip. The rewrite droppedmov rdi, raxbefore the tail jump, so the callee read the caller's packed-string pointer as an IPv4
integer and rendered an unrelated address on every x86_64 target. Restored, and the IPv4
emitter test now asserts the move as well as the jump.
inet_pton(3)takesfe80::1%eth0on Darwin and refuses it on glibc — measured both ways. PHP has the samesplit, for the same reason, so elephc's behaviour was right and its claim was wrong. The
portable fixtures, the docs and the example no longer assert the macOS answer; the docs state
the divergence as the inherited libc behaviour it is.
_concat_buf + _concat_off, as the IPv4-only version did for its four bytes; sixteen bytesof packed address, or up to 45 of rendering, run off the end of the 64 KiB buffer once an
earlier result has pushed the cursor near it. Both now reserve through
__rt_concat_reserveand publish through__rt_concat_publish, which brings the heapfallback with it. A unit test per helper asserts both calls are present and that
_concat_offis never named directly.Verified
Against host PHP 8.5.10, byte-identical across the shapes that are portable: the issue's own
reproducer;
::1,::, the expanded and compressed spellings of the same address,::ffff:mapped addresses; the IPv4 family and its invalid inputs (
256.0.0.1,1.2.3, empty); IPv6inputs that must stay invalid (
1:2:3:4:5:6:7:8:9,gggg::1); and round trips through bothhelpers, including the canonical spelling
inet_ntop()picks when several are possible.Both helpers write into the shared concat buffer, so a fixture keeps three packed addresses
alive at once, puts calls inside a concatenation that is also writing there, and runs 50 round
trips in a loop.
The five codegen tests were confirmed load-bearing by reverting both helpers: four fail, and
the fifth is the IPv4 guard, which is meant to pass either way.
cargo test --test codegen_tests -- codegen::stringspasses in full (357), as do 1922 unittests and 1542 error tests.
One deliberate divergence
The copy into the NUL-terminated buffer bounds the input at 255 bytes; anything longer is
falsewithout being parsed. Reference PHP has no bound. That bound cannot be isolatedportably — the only longer inputs any platform would otherwise parse carry a zone identifier —
so what the fixture pins is the observable contract: long junk is
false, and the refusalleaves nothing behind for the calls after it.
Docs and example
docs/php/strings.mdstates both families, the spellings accepted everywhere, what renderingcanonicalizes, the zone-identifier split, and the input bound.
docs/internals/the-runtime.mdrecords which half is hand-written and which is libc, how thefamily is chosen, why
AF_INET6is pinned per target, and that both destinations are reservedrather than taken from the cursor.
examples/ip-conversion/main.phppacks and renders IPv6 beside the IPv4 it already had: thebyte length that distinguishes the families, the canonical spelling, an IPv4-mapped address,
and the
falsecases. Its output is verbatim host PHP 8.5.10.🤖 Generated with Claude Code
https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr