Skip to content

feat(network): inet_pton()/inet_ntop() cover IPv6, the family they always claimed - #1056

Open
Guikingone wants to merge 3 commits into
mainfrom
fix/692-inet-pton-ipv6
Open

Guikingone wants to merge 3 commits into
mainfrom
fix/692-inet-pton-ipv6

Conversation

@Guikingone

@Guikingone Guikingone commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #692.

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 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 into reserved scratch. __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.

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.

  • The x86_64 IPv4 address never reached long2ip. The rewrite dropped mov rdi, rax
    before 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.
  • 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. PHP has the same
    split, 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.
  • 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 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_reserve and publish through __rt_concat_publish, which brings the heap
    fallback with it. A unit test per helper asserts both calls are present and that
    _concat_off is 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); 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.

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::strings passes in full (357), as do 1922 unit
tests and 1542 error tests.

One deliberate divergence

The copy into the NUL-terminated buffer bounds the input at 255 bytes; anything longer is
false without being parsed. Reference PHP has no bound. That bound cannot be isolated
portably — 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 refusal
leaves nothing behind for the calls after it.

Docs and example

docs/php/strings.md states both families, the spellings accepted everywhere, what rendering
canonicalizes, the zone-identifier split, and the input bound.

docs/internals/the-runtime.md records which half is hand-written and which is libc, how the
family is chosen, why AF_INET6 is pinned per target, and that both destinations are reserved
rather than taken from the cursor.

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,
and the false cases. Its output is verbatim host PHP 8.5.10.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr

@github-actions github-actions Bot added area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities. labels Sep 16, 2026
@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; all previous findings are resolved and no new actionable defects remain.

Summary

This PR extends inet_pton() and inet_ntop() from IPv4-only behavior to IPv4 and IPv6 while preserving target-specific ABI and libc behavior.

  • Delegates IPv6 parsing and rendering to the target platform’s inet_pton(3) and inet_ntop(3).
  • Uses target-specific AF_INET6 constants across Darwin and Linux targets.
  • Reserves and publishes concat-buffer storage safely, including cleanup after failed parsing.
  • Adds cross-target emitter tests, end-to-end address fixtures, diagnostic tests, documentation, and an updated example.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[PHP inet_pton text] --> B{Contains colon?}
  B -->|No| C[AF_INET / 4-byte reservation]
  B -->|Yes| D[Target AF_INET6 / 16-byte reservation]
  C --> E[libc inet_pton]
  D --> E
  E -->|Success| F[Publish packed bytes]
  E -->|Failure| G[Release unpublished reservation]
  H[PHP inet_ntop packed bytes] --> I{Packed length}
  I -->|4 bytes| J[Existing long2ip path]
  I -->|16 bytes| K[libc inet_ntop]
  I -->|Other| L[Return false]
  K --> M[Reserve and publish rendered text]
Loading

Reviews (4) · Last reviewed commit: "fix(network): release an inet_pton() res..."

Comment thread src/codegen_support/runtime/strings/inet_ntop.rs
Comment thread tests/codegen/strings/inet.rs Outdated
Comment thread src/codegen_support/runtime/strings/inet_pton.rs Outdated
Comment thread src/codegen_support/runtime/strings/inet_pton.rs Outdated
Comment thread tests/codegen/strings/inet.rs
…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
Guikingone force-pushed the fix/692-inet-pton-ipv6 branch from 61ae9c2 to 41e1a38 Compare September 18, 2026 13:10
@Guikingone Guikingone self-assigned this Sep 18, 2026
@Guikingone
Guikingone requested a review from nahime0 September 18, 2026 14:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

inet_pton() rejects valid IPv6 addresses

1 participant