c_ptr: an opaque handle that crosses without being trusted
Status: built (edition 3). §3’s proposal is implemented as written:
Type::CPtr, valid in bothextern fnparameter and return position,val, equality/nullness only, withnull_ptr()as the one non-foreign producer.tests/accept/opaque_pointer.choexercises the full round trip — a foreign call returning a handle, the same handle passed to a second foreign call, and a null comparison on a real failure — through real libc (fdopen/fclose) on both backends (crates/cancho/tests/conformance/backends.rs’sthe_two_backends_agree_on_an_opaque_pointer); threetests/reject/fixtures pin arithmetic, coercion, and the “not a general type” rule §4 predicted. §5’s TLS example was not built at the time: it needed the build pipeline to link a library beyond libc, which nothing in this project had ever needed before. Corrected:docs/foreign-linking.mdclosed that gap and built the example —examples/tls_client/, a real handshake and an encrypted round trip againstopenssl s_serveron both backends, that document’s §4 has the transcript. §1’s real OpenSSL signatures and §3’s design are otherwise exactly what shipped — reached by writing a first accept fixture againstfopen/fopen’s two string parameters, watching it segfault, and finding the actual bug: a pre-existing,c_ptr-unrelated gap in how a&r [byte]parameter crosses (§4 note below), not anything wrong with the type this document proposed.
docs/reach.md§3.1 named the rule this document has to satisfy — “a value crosses into a cancho program only if the checker can say where it came from” — and then listed everything that rule puts out of reach: OpenSSL, libpq, libcurl,FILE *,dlopen. This is the design for letting the first of those in, TLS specifically, without weakening that rule. It was written before any code, per this project’s own “design before code” discipline (CLAUDE.md), and grounded against the real OpenSSL 3.0 header and a live loopback handshake, not a remembered signature.
1. What TLS actually asks for
docs/reach.md §2’s table already answers “can it reach TLS?” with
No, and names the reason: SSL_CTX * (§3.1). The real OpenSSL
client handshake this document verified against (/usr/include/openssl/ ssl.h, OpenSSL 3.0.13, confirmed present in this environment via
openssl version, pkg-config --exists openssl, and every symbol below
resolved live in libssl.so.3 with nm -D) is nine calls:
const SSL_METHOD *TLS_client_method(void);
SSL_CTX *SSL_CTX_new(const SSL_METHOD *meth);
SSL *SSL_new(SSL_CTX *ctx);
int SSL_set_fd(SSL *s, int fd);
int SSL_connect(SSL *ssl);
int SSL_write(SSL *ssl, const void *buf, int num);
int SSL_read(SSL *ssl, void *buf, int num);
int SSL_get_error(const SSL *s, int ret_code);
int SSL_shutdown(SSL *s);
void SSL_free(SSL *ssl);
void SSL_CTX_free(SSL_CTX *);
Split these by what §3.1 already handles and what it does not:
int fd,int num, everyintreturn — alreadyint, already crosses today.SSL_write/SSL_read’svoid *buf/const void *bufare not opaque handles at all; they are the same “a length and a pointer that cannot disagree” shapewrite/readalready use (strings.md§6), so they cross as&r [byte]exactly like an existingread/writedeclaration, unchanged by this document.const SSL_METHOD *,SSL_CTX *,SSL *— three distinct opaque handle types, each produced by one call and consumed by the next few, never inspected, never dereferenced, only threaded through in order and eventually freed. This is the whole gap. Three call sites return a handle (TLS_client_method,SSL_CTX_new,SSL_new); five take one as a parameter (SSL_set_fd,SSL_connect,SSL_write,SSL_read,SSL_get_error,SSL_shutdown,SSL_free,SSL_CTX_free— eight, not five; corrected count below in §3).
So the feature this document proposes is narrow on purpose: a value that
can be returned by and passed to a foreign function, compared for
nullness, and nothing else. Not a general pointer type, not a malloc
story (reach.md §3.1 is right that nothing here wants that), not a
struct-crossing story (§3.2 already has its own answer, unrelated).
2. Why c_int is the wrong template, and where it still helps
c_int (reach.md §3.4) looks like the closest precedent — a named
type that exists only to make one foreign crossing honest — but its
mechanism is the opposite of what a handle needs. c_int is recognized
by name at exactly one syntactic position (extern fn’s return type,
crates/cancho-ir/src/lib.rs) and collapses immediately to plain
Type::Int for everything downstream: every assignment, comparison
and arithmetic operation on a c_int result sees an ordinary 64-bit
int from the very next line on. That collapse is exactly right for
c_int, because the thing it is narrowing — a real C int32_t that
gets sign-extended once at the boundary — really is an integer
afterward, with real arithmetic and real comparisons against 0.
A handle is not an integer afterward. let s: &static [byte] = ssl_ctx;
must stay an error the same way reach.md §3.1.1 shows it staying an
error for malloc’s pointer today, and ssl_ctx + 1 must be an error
too — neither of which c_int’s collapse-to-Int trick can give,
because collapsing to Int is precisely what makes arithmetic and
comparison legal. So c_ptr needs to remain a distinct type all the
way through the program, not a spelling that resolves away at parse
time. What c_ptr does keep from c_int’s precedent: a foreign
return is exactly where extra scrutiny already lives in this
codebase (§3.4’s whole point), and the same trust posture applies here
— the declaration is checked for shape, never against the real header
(reach.md §3.1.1: “the declaration is trusted — nothing checks a
cancho signature against the C header”).
3. The proposal
A new type, Type::CPtr, spelled c_ptr in source. Unlike
c_int, it is a real type, not a resolve-away name:
- Valid in both parameter and return position of an
extern fndeclaration —c_intis return-only because only a return has the width problem; a handle has no width problem (it is opaque, never read), but it flows in both directions (SSL_CTX_newreturns one,SSL_newtakes one), so the restrictionc_intneeds does not apply here. val, not linear. The existing capability/region system exists to track ownership of something this program allocated and must release exactly once. Ac_ptris not owned by this program at all — it is C’s memory, released by callingSSL_free/SSL_CTX_free, and nothing here can express “must callSSL_freeexactly once” any more than it expresses “must callcloseexactly once” for a rawintfile descriptor today. That is not a new gapc_ptropens; it is the same gapreach.md§3.1.1 already accepts forfork’s pid and every file descriptor this language already hands back integer-shaped. Ac_ptris copyable and comparable, freely, the same as anintis.- Equality and nullness only.
==,!=, and a comparison against a literal null handle. No arithmetic (+,-, indexing), no dereference, no coercion to or from any reference type, no coercion to or fromint. This is what keeps §3.1’s rule intact: the checker never claims to know ac_ptr’s provenance the way it knows a reference’s region, so it never lets ac_ptrbe used as if it did. Anintis safe to accept because “a number’s provenance is nothing” (§3.1) — ac_ptris safe to accept for exactly the same reason, as long as the only things done with it are the things that also don’t need provenance: pass it back, compare it, check it against null. - A null sentinel,
null_ptr(), a builtin of typec_ptr— needed because OpenSSL’s own error convention is “returnsNULLon failure” forSSL_CTX_newandSSL_newalike, and the program needs ac_ptrvalue to compare against without ever constructing one from anint(which the no-coercion rule above forbids).null_ptr()is the one producer of ac_ptrthat is not a foreign call’s return.
Corrected count from §1: eight foreign declarations become the surface
this touches — three returning c_ptr (TLS_client_method,
SSL_CTX_new, SSL_new), and the same handle type appearing as a
parameter in SSL_set_fd, SSL_connect, SSL_write, SSL_read,
SSL_get_error, SSL_shutdown, SSL_free, SSL_CTX_free (eight
consumers, some declarations consuming more than one handle type at
once — SSL_set_fd takes SSL * and an int, not two handles).
What this does not solve
- No
struct sockaddr_in-style layout problem exists for TLS. Every OpenSSL call in §1 takes only handles, integers, or a length-carrying byte slice — there is no struct to build the waybindneeds one (reach.md§3.2). This document has nothing to add to §3.2’s answer. - Does not open
malloc. Ac_ptrcannot be dereferenced, indexed, or offset, so it is useless for the thingreach.md§3.1 already refuses on purpose (“the heap is a capability withbox”). Nothing about accepting a handle back fromSSL_CTX_newlets a program treat it as addressable memory. - Does not give “must free exactly once.” Stated above; a
c_ptrleak is possible, the same way an unclosed file descriptor is possible today. A future capability wrappingc_ptrthe wayheap.mdwraps raw allocation is future work, not required for TLS to work at all, and not proposed here. - Does not solve certificate verification’s own trust story — a
correct TLS client needs
SSL_CTX_set_verifyand a trust store, which are more foreign declarations of the same three shapes already covered (c_ptr,int, byte slice), not a new type-system problem. Left to the example program in §5, not this design.
4. Where this plugs into the existing structure
(Sequencing for the implementation that follows this document — not itself part of the design decision.)
crates/cancho-types: a newType::CPtrvariant alongsideInt,Byte,Bool, ….- The resolver/name layer that recognizes
c_intby string incrates/cancho-ir/src/lib.rs’s extern-fn checking gains a sibling branch forc_ptr, constructingType::CPtrinstead of collapsing toType::Int, allowed in both the parameter and return position checks (currentlyType::Int | Type::Boolfor parameters,Type::Int | Type::Bool | Type::Unitfor returns).c_ptr, likec_int, is not registered as a general type name anywhere else, so writing it in any other position stays an unresolved-name refusal for free — the same reasonc_intcannot be written outside an extern return today. the binary-operator type checker gainsType::CPtrto its equality/inequality arms and excludes it from every arithmetic and ordering arm. - Both backends’ scalar-classification (
scalar_kindincancho-codegen-llvm, and Cranelift’s equivalent width lowering) treatType::CPtras an i64-width scalar for register purposes — the same widthintalready gets, since the handle rides in a register exactly like an integer-shaped file descriptor does.null_ptr()lowers to a 64-bit zero constant on both backends. - Differential coverage:
crates/cancho/tests/conformance/backends.rsgets a case exercising ac_ptr-returning andc_ptr-consuming extern declaration on both backends, mirroringthe_two_backends_agree_on_a_narrow_foreign_return’s existing pattern forc_int. tests/reject/: a case provingc_ptrarithmetic, dereference, and coercion to/fromintor a reference type are all refused — the positive mirror ofreach.md§3.1.1’smallocexample, now checked by the compiler instead of only by prose.
5. Verification plan
A design that only compiles is not verified; reach.md’s whole method
is building the thing and reporting what happened. The plan:
- A new example,
examples/tls_client/, declares the nine functions in §1 (plusSSL_CTX_set_verify/a trust-store call for a real, not toy, verification posture) usingc_ptrfor every handle. - It connects to a real TLS server over loopback:
openssl s_server -accept <port> -cert <cert> -key <key>(already available — this environment has genuine OpenSSL 3.0.13, confirmed viaopenssl version), sends a request, and reads the response back through the existing&r [byte]byte-slice crossing. - The test harness (
crates/cancho/tests/conformance/, following the existing pattern forexamples/serve/’san_http_server_written_in_cancho_answers_a_real_request) startsopenssl s_serveras a subprocess, runs the compiled example against it, and asserts the plaintext response bytes match — a real handshake and a real encrypted round trip, not a stub. - Both backends run the same example through
crates/cancho/tests/conformance/backends.rs’s existing--backend cranelift/--backend llvmsplit, soc_ptris proven on both, not just the default.
If step 2 or 3 cannot be made reliable in CI (a subprocess-managed real
TLS server is a heavier test fixture than anything this suite runs
today), the fallback is the same one docs/self-hosting.md used for a
comparably large claim: land the type-system change and its tests/ accept/tests/reject coverage on its own, and record the live example
as a spike verified once by hand in this document (with its exact
output) rather than as a CI-gated test — a decision to make explicit at
implementation time, not assumed here.
Corrected: this is the fallback that was taken, for a reason
independent of c_ptr. Step 1 needs the final linking step to pull
in -lssl -lcrypto; every example this project has ever built links
only libc, which the compiler always links, so nothing in the current
cancho build pipeline can express “also link this library” at all.
Adding that is a real, separate feature — a general “link an external
library” story, not a c_ptr question — so §1’s nine declarations were
grounded against the real header and never compiled into a program.
tests/accept/opaque_pointer.cho verifies the type feature itself
end-to-end against fdopen/fclose instead, real libc, no extra
linking.
6. What building the fixture found, unrelated to c_ptr
The first attempt at that fixture declared fopen(path: &p [byte], mode: &m [byte]) -> c_ptr — two string parameters, matching real
fopen’s own signature — and segfaulted on both backends. gdb on the
core: _IO_new_file_fopen received mode=0xe, a small integer where a
pointer belongs. The cause has nothing to do with c_ptr: a &r [byte] parameter already crosses as two real arguments, a pointer
and a separate length (docs/strings.md §6), and path’s own length
leaf (14, len("/etc/hostname") + 1) landed in the register real
fopen reads as its second parameter, because fopen was never
compiled expecting that extra register at all. path’s pointer arrived
correctly (it is the first real argument either way); mode’s
pointer was silently dropped.
This means a &r [byte] parameter is only safe as the last
crossing parameter of an extern fn declaration. write/read’s
own long-standing declarations already follow this by construction —
fd: int first, the slice last, nothing after it — and so does every
declaration in §1: not one OpenSSL function needed two string-shaped
parameters, so this gap never touched the TLS design at all. The
fixture that found it was rewritten around fdopen(fd: int, mode: &m [byte]), matching the safe shape exactly, rather than working around
the gap. Declaring a real C function that takes two NUL-terminated
strings (fopen’s own path, mode included) has no correct spelling
in this language today — a pre-existing limitation of the byte-slice
crossing convention, not of c_ptr, and out of scope for this
document to fix. Worth a line in docs/strings.md §6 or a tests/ reject/ fixture of its own; left as found, not chased, the same
judgment call docs/llvm-backend.md’s own corrections made about
scope.