ADR-147: The C Boundary Has a View and an Opaque Handle, and No Raw Pointer

Status: Superseded by ADR-302 — its D1-D5 are D5-D9 there (D4 as narrowed by ADR-155 D4) Date: September 19, 2026 Target Version: Nikaia 0.0.31 Related: ADR-124 §4 (which left this open — the only part of that record not built), ADR-284 (the other direction, answered the same way), ADR-284 (a C value struct, and NK1145), ADR-094 (a view is what the compiler writes), Part I 6.4 (cleanup), Part I 6.6 (a view is tethered), Part III 15.1

1. Context

extern "C" { fn malloc(size: usize) -> Pointer[u8] } is refused with NK1135, because Pointer[T] is a type nothing declares — and ADR-124 §4 left it that way on purpose: a pointer that outlives what it points at wants a record with a lifetime story, not a name.

What that costs is most of C. Every function whose signature has a pointer in it is unwritable: all of libc’s memory surface, and every library that hands out a handle — a database connection, an HTTP client, a compressor. getpid compiles; nothing with a buffer or a handle does.

The thing to prevent is the dangling dereference. A raw pointer admits a value that can dangle and makes every dereference a hole the compiler cannot close. But the two shapes real C libraries are made of — a buffer the caller owns and a handle the library owns — can each be given a form that makes a dangling dereference impossible by construction, and both forms are words this language already has.

2. Decisions

D1 — A buffer is a view, and it lives for the call.

extern "C" {
    fn read(fd: i32, buf: &mut [u8], count: usize) -> isize
    fn strlen(s: &[u8]) -> usize
}

&T, &mut T, &[u8] and &mut [u8] in an extern declaration lower to the pointer C wants and live for the call, which is what a view is everywhere else in this language (ADR-094). Nothing is stored, nothing escapes, and the tether of Part I 6.6 is the same one.

D2 — A length beside a view is checked at the call.

read(fd, buf, count) declares count: usize and the compiler refuses a call where count cannot be shown to be at most buf.len(). The two parameters are one fact in C and the declaration is where this language says so; a call that passes a longer length is the buffer overrun the boundary exists to stop, and it is refused here rather than by the operating system.

Where the length is not a constant and not buf.len(), the call is refused asking for one of those two — which is narrow on purpose, and is the same refuse rather than guess every other check here keeps.

D3 — A handle is opaque, declared, and released by a cleanup.

extern "C" {
    opaque type sqlite3 released by sqlite3_close
    fn sqlite3_open(path: &[u8], db: &mut sqlite3) -> i32
}

An opaque type is an address the language never dereferences. It is moved and stored like any value, it has no fields and no indexing, and its release is a cleanup the compiler runs at the end of its scope (Part I 6.4) — so a handle cannot outlive what it names unless a C function makes it, which is the one thing this language cannot check and says so.

D4 — Returned text is a handle a std function copies.

getenv hands back a C string, which is memory the caller does not own and whose lifetime is the library’s. It arrives as an opaque CStr handle, and std copies it into a String inside unsafe — one function, written once, where every program would otherwise write the same loop.

D5 — malloc stays unwritable, by design.

Memory the language will index has to arrive with a length the language knows. malloc hands back an address and no length; there is no shape here for it, and that is the decision rather than a gap. A program that needs a buffer makes one in Nikaia and lends it (D1).

3. Consequences

4. What this does not decide

5. What is built

All of it. Steps 1 to 4, D5 by Pointer[u8] still being NK1135 and this record saying that is permanent rather than pending, and step 5 — examples/sqlite/main.nika, which opens a database, prepares a statement, steps it and reads a row back, and whose test skips rather than fails where the machine has no libsqlite3.

The four forms, and where each may stand. &T, &mut T, &[T] and &mut [T] parse, lower to *const T / *mut T, reach the ledger in this language’s own words, and are refused with NK1158 outside an extern "C" declaration — because away from the boundary a parameter this language may change is written mut name: T (ADR-094 D3) and a run of elements is a Vec[T] or an Array[T, N], each of which carries its length.

Rust’s &[T] is a fat pointer, which is why the declaration writes the address. A declaration that lowered &[u8] to Rust’s own &[u8] would be a signature the two languages disagree about, and what a reader would get for it is rustc’s improper_ctypes about a file nobody wrote (Part III C.1). So a foreign declaration has a type writer of its own, and the length is D2’s — the two parameters are one fact in C and the declaration is where this language says so.

The call is where the address is made, and it is the emitter’s own answer rather than a hand-over from the checker: bytes becomes bytes.as_ptr(), and a foreign declaration is a name in the very file the emitter is writing, which it already resolves by name (ADR-296 D17). One call serves every caller, because a Vec, an Array and text all answer .as_ptr().

Ty::Pointed is a variant and not two flags on Named, which is the opposite of how the AST records it. There are seventy-eight places in this compiler that build a Named and not one of them can produce either shape, so a field they would all have to answer for is a field for one position’s sake. A plain &T stays Named with view: it is the view every declaration in this language writes, and a shape that held the third case too would have two spellings for one type.

The fit is where the declaration and the caller meet. A &[u8] takes whatever lends a run of u8 — a Vec[u8], an Array[u8, N], a String or a &str — and one direction only: nothing fits out of a boundary type, because what a C function hands back is an address and the value this language would make of it is D3’s handle or D4’s copy.

What makes a pair, for D2, is the declaration’s own types: a &[T] and, right after it, a usize. The type says length rather than the name, because usize is not a type this language’s own values have (ADR-285 D1) — a length here is an i64 and the machine-width type left the surface a program can write. A declaration that writes one beside a buffer is therefore saying C’s size_t, and D2 is what that says.

Two shapes are accepted, which is D2’s narrow on purpose: the buffer’s own len(), and a constant the buffer’s length is known to cover — an Array[T, N], whose length is part of its type (ADR-152 D1). A zero is accepted against any buffer, because no length is smaller. A Vec with a constant is refused, and correctly: nothing here can show what its length will be.

What it costs is a usize beside a buffer that is not its length. An offset in that position has no way to say so, and the call is refused. That is D2’s narrowness rather than a defect in it — the record chose the pair as definitional — and it is written here so the day a program meets it, it is met as a decision.

D2 turned up two defects of the compiler’s own, both invisible until a declaration named usize, which no program could write before this record. keeps::moves did not list usize or isize, so a type it did not name was one that moves and ADR-094 D1 lent the count — read(0, buf.as_mut_ptr(), &buf.len() as i64), which is not Rust. And a usize parameter had no way to take this language’s i64, so buf.len() met NK1102 with a help naming as usize — a cast into a type Part I 2.2 does not offer. Both are fixed: the two names join the copy list, and a size at the boundary takes an i64 with the conversion emitted, which is the arrangement str::repeat already had, arrived at from the declaration rather than from a name.

Fifteen tests in crates/nikaia/tests/foreign_pointers.rs, one of which compiles the lowering with rustc against libc’s own strlen and abs and runs it — which needs no library the machine may not have, and is the whole of D1 end to end.

A handle is #[repr(transparent)] over the address, and Drop is the cleanup. The layout is the whole point: a handle is the address, so &mut T at the boundary is T** the way C writes it and a handle passed by value is the pointer. A newtype Rust may lay out as it likes would be a different program at the boundary. Drop is Part I 6.4’s cleanup read here — the release runs at the end of the handle’s scope, so a handle cannot be forgotten; what it cannot answer for is a C function that keeps the address past its own call, which is the one thing this language cannot check and D3 says so.

A handle is lent to every declaration but one, and that is D1’s lesson one type over. fileno(f) reads the handle and f is still the caller’s to close, so the address goes by value and the value stays here (f.lent()); the type’s own release function takes it, and Rust’s own move is what keeps it from being released twice. The & ADR-094 D1 would otherwise write is the wrong address as well as the wrong ownership — &FILE is FILE** where C wants FILE* — which is the same mistake Rust’s fat &[T] would have been in a declaration.

NK1160 covers all three ways of reaching past a handle: a field, an index, and a call, because an opaque handle has no constructor. There is no value of one this language can make, so a constructor would have to invent an address — and an address somebody chose is the one thing a handle may never be.

None of opaque, type, released and by is reserved. The grammar is scannerless, so a word means something only where a rule asks for it, and all four are names a program may want. Reserving one buys exactly the sentence a reader who writes it gets (ADR-298 D7), and this position says that sentence without taking the word away.

What D3 does not decide, and it is its own example. sqlite3_open(path, db) needs db to exist before the call, C writes it as an uninitialised pointer, this language has no uninitialised binding, and D3 gives a handle no constructor — so the record’s own out-parameter is not a program. NK1160 says so rather than letting rustc answer. The question is in open-decisions.md with a recommendation, and what is built meanwhile is every library whose constructor hands its handle back, which is fopen’s shape and most of C’s.

D4’s handle is opaque and has no cleanup, which is what tells it from D3’s: a FILE is ours to close and a C string is not ours at all, so std::CStr frees nothing. The walk to the zero byte is the one thing a program must not have to write, so it is written once, inside unsafe, in nikaia-std, and a program says raw.to_string() with no unsafe of its own.

And it fails rather than guessing, in the two ways it can: the address may be null, which is what every C function that finds nothing hands back, and the bytes may not be UTF-8, which text in this language is. Both are throws rather than an abort, because a caller can do something about either — getenv finding nothing is an ordinary Tuesday, and Part III A.2’s aborts are for what a program’s own arithmetic got wrong. When the question below is answered the null half becomes a CStr? and stops being a failure; the UTF-8 half stays one, which is what fs::read_to_string already is.

Thirty-two tests in crates/nikaia/tests/foreign_pointers.rs, four of which compile the lowering with rustc against libc and run it — strlen and abs for D1, fopen/fclose/fileno for D3, getenv for D4, posix_memalign’s out-parameter for ADR-155 — and a fifth against sqlite3 that skips where the package is not there.

Step 5 earned its place, which is the part worth writing down. It is a test and not a decision, so it looked like bookkeeping — and it found three things the four libc programs could not.

It found that the four decisions are not four. sqlite3_prepare_v2 takes the database the out-parameter filled, and a sqlite3? cannot be handed to a parameter that takes a sqlite3. The language turned out to have the answer already — slot ?? throw Refused::NoDatabase narrows a T? to a T, which is ADR-276 D20’s jump-as-an-expression meeting ADR-279’s ?? — but nothing had ever written the two together, and four libc calls never would have.

It found a defect of this record’s own. A C type keeps the name its header gives it, and pub struct sqlite3 is not the shape Rust’s own lint expects, so rustc warned about the generated file — Part III C.1’s class, and invisible until a library with a lower-case type name was called.

And it found one older than this record. throw "no database" reads like a program and is not one: Part I 7.1 says what is thrown implements Error, and text does not. rustc answered the trait bound str: Error is not satisfied about a file nobody wrote. It had been writable since throw existed and no program in the tree had written one. NK1161 is that refusal in this compiler’s words.

libc is still what proves the four, on any machine, and sqlite3 is what proves they hold for a library rather than for four calls. That is why the test skips rather than fails where the package is missing: a gate that depends on a library not every machine has is a gate people learn to ignore.

This site is open source. Improve this page.