Skip to main content

nova_runtime/
fs.rs

1//! Filesystem intrinsics for `std/fs`.
2//!
3//! # The boundary this module implements
4//!
5//! Nova has no out-parameters, so an intrinsic returns exactly one word — but
6//! `read_to_string` must convey a `String` *and* a two-field `IoError`. Rather
7//! than build a Nova sum and record here, which would put Nova value layout in
8//! Rust, **the status code returned by each operation *is* the error kind**, and
9//! payloads travel in the slots below. `std/fs`'s Nova wrappers map a status to
10//! an `IoErrorKind` and construct every `Result` and `IoError` themselves.
11//!
12//! # Why `SLOTS` is a `RefCell` guarded with `try_borrow_mut`, not `borrow_mut`
13//!
14//! **Every function here is called from inside an `async fn`'s generated
15//! `$poll`, which has no landing pads** (see `nova_mir::async_lower`'s module
16//! doc comment), so nothing here may panic. `RefCell::borrow_mut` panics on an
17//! already-outstanding borrow; `try_borrow_mut` cannot, so `with_slot` uses
18//! that instead and falls back to `abort_with` -- which terminates without
19//! unwinding -- on the contended case. Each slot still holds nothing more
20//! than a raw already-allocated pointer as a `usize`, with `0` meaning empty;
21//! the per-task table just adds one layer of indirection to reach it, keyed
22//! by `slot_index`.
23//!
24//! `stash` itself is `pub(crate)`: `std/fs` is not its only consumer.
25//! `crates/nova-runtime/src/io.rs` stashes the three standard streams'
26//! payloads and write byte counts through this identical slot table, and
27//! `crates/nova-runtime/src/crypto.rs` stashes digests, HMAC tags and random
28//! bytes the same way, rather than either module owning a second one.
29//! `take` stays private (final review, M4) -- `io.rs`'s own production code
30//! only ever stashes, and its tests reach `take` through the test-only
31//! `take_for_test` instead of `take` itself being widened for their sake.
32//!
33//! # Why the pointer is GC-rooted while it sits in a slot
34//!
35//! A slot is written by one intrinsic and read by the next, with Nova code in
36//! between. A conservative stack scan cannot see a pointer that lives only in a
37//! thread-local, so the object is registered with `gc::add_root` while stashed
38//! and released on take — the same balance `task::spawn_internal` and
39//! `take_output_internal` keep. Without it the payload could be collected
40//! between the two calls, which is why this does not merely rely on "no
41//! allocation happens in between".
42
43use crate::{gc, NovaStr};
44use std::cell::RefCell;
45
46/// Status codes. `0` is success; every other value is an `IoErrorKind`.
47///
48/// **This numbering is one half of a wire contract.** The other half is
49/// `io_error_kind_of` in `std/io/lib.nova`. The two are independent copies.
50///
51/// **MEASURED, not "pinned together by a fixture per kind" for all eight —
52/// that claim was false and this replaces it (final review, I1).** The Nova
53/// half (`io_error_kind_of`'s own arms) is pinned per-code by
54/// `fs_io_types.nova`. This Rust half, the one that produces a code from a
55/// real `std::io::ErrorKind` below, is pinned by a fixture that provokes the
56/// real OS condition for only four of the eight: `NotFound`, `AlreadyExists`,
57/// `InvalidData`, and `PermissionDenied` on Windows only.
58///
59/// **Corrected 2026-08-14 (branch `file-open-openoptions`): naming one
60/// fixture per kind, as this comment used to, went stale for three of those
61/// four the moment `file.rs`'s `open` began producing this same numbering
62/// from a second Rust source.** Per kind, now:
63/// - `NotFound`: `fs_not_found.nova` (`read_to_string` on a missing path),
64///   and independently `file_errors.nova` (`open` on a path under a missing
65///   parent directory).
66/// - `AlreadyExists`: `fs_already_exists.nova` (`create_dir` on an existing
67///   path, twice), and independently `file_errors.nova` (`open` with
68///   `create_new` on an existing path).
69/// - `InvalidData`: `fs_invalid_data.nova` only — `File::read` hands back
70///   `Bytes` and never decodes UTF-8, so nothing about `File` can provoke
71///   this one.
72/// - `PermissionDenied`, Windows only: `fs_permission_denied.nova`
73///   (`read_to_string` on a directory, `#[cfg(windows)]`), and independently
74///   `file_open_dir.nova` (`open` on a directory, `#[cfg(windows)]`).
75///
76/// `Interrupted`, `TimedOut` and `ConnectionRefused` are not reachable from
77/// `std/fs`'s `async fn`s at all, portably or otherwise, so no fixture can pin
78/// them. **Reasoned, not measured, for `read`/`write` (`byte-type` branch):**
79/// both route through this same `fail` function exactly as
80/// `read_to_string`/`write_string` do, over `std::fs::read`/`std::fs::write`
81/// rather than `std::fs::read_to_string`/`std::fs::write` — reading or
82/// writing raw bytes instead of UTF-8 text opens no path to a network- or
83/// signal-flavoured `ErrorKind` that plain-text local I/O did not already
84/// have, so the same unreachability is expected to hold, not separately
85/// provoked. The `_ => OTHER` fallback *is* reachable (e.g. `read_dir` on a
86/// plain file returns it, still unexercised).
87///
88/// **Corrected 2026-08-14: it is no longer true that nothing exercises this
89/// fallback.** `file.rs`'s closed/stale/forged-fd case (`closed_fd_error`)
90/// fabricates a `std::io::Error` of `ErrorKind::Other` on every table miss,
91/// unconditionally, which falls to this identical `_` arm — and
92/// `file_lifetime.nova`'s "read after close" check asserts on the exact
93/// message that arm stashes. See `docs/adr/0011-io-error-kinds.md`, which
94/// states this split correctly as of the same correction.
95///
96/// **Corrected 2026-08-16 (branch `io-poller-std-net`): the "not reachable ...
97/// so no fixture can pin them" paragraph above is now stale for two of its
98/// three kinds, and the per-kind breakdown two paragraphs up goes four
99/// fixture-pinned kinds to six.** `TIMED_OUT` and `CONNECTION_REFUSED` have
100/// carried this numbering with no producer since increment 1, because no
101/// filesystem operation can produce either — `crates/nova-runtime/src/net.rs`'s
102/// two-phase `connect` and its `read_timeout` future are their first. A
103/// `connect` to a loopback port nothing is listening on is refused either
104/// synchronously (an immediate `ECONNREFUSED`/`WSAECONNREFUSED`, routine for
105/// a closed loopback port) or via the second poll's `SO_ERROR` check, both
106/// paths through this identical `fail` (`net.rs`'s own module doc comment,
107/// "`CONNECTION_REFUSED` gets its first producer"), pinned end to end by
108/// `tests/runtime/net_refused.nova` (`net_refused_run`,
109/// `crates/nova-cli/tests/run_tests.rs`). `read_timeout` reports `TIMED_OUT`
110/// when its deadline passes with a read still would-blocking, pinned by
111/// `tests/runtime/net_timeout.nova` (`net_timeout_run`), which also pins the
112/// opposite direction in the same fixture — a deadline comfortably longer
113/// than the peer's own delay must still succeed with the real data, not fire
114/// merely because a deadline is in play at all. Six of the eight status
115/// codes are therefore fixture-pinned now: the original four two paragraphs
116/// up, plus these two. Only `INTERRUPTED` remains genuinely unreachable from
117/// either module's `async fn`s, portably or otherwise — the "reasoned, not
118/// measured" argument above, made for all three together, now supports
119/// `INTERRUPTED` alone.
120pub const OK: i64 = 0;
121pub const NOT_FOUND: i64 = 1;
122pub const PERMISSION_DENIED: i64 = 2;
123pub const ALREADY_EXISTS: i64 = 3;
124pub const INVALID_DATA: i64 = 4;
125pub const INTERRUPTED: i64 = 5;
126pub const TIMED_OUT: i64 = 6;
127pub const CONNECTION_REFUSED: i64 = 7;
128pub const OTHER: i64 = 8;
129
130/// Which payload a `stash`/`take` pair is addressing.
131///
132/// Replaces the three separate `thread_local!` slots this module used before
133/// per-task storage. `Buffer` serves `String` *and* `Bytes` payloads, not one
134/// variant each: the two have the identical `{len, ptr}` representation (see
135/// `crate::bytes`'s module doc comment), and which one a payload is gets
136/// carried entirely by which builtin stashed it and which one reads it back
137/// (`nova_rt_fs_read_to_string`/`nova_rt_fs_take_string` treat it as UTF-8;
138/// `nova_rt_fs_read`/`nova_rt_fs_take_bytes` do not interpret it at all). A
139/// dedicated `Bytes` slot would be a fourth copy of storage for a distinction
140/// already carried by which builtin stashed it — exactly the kind of
141/// avoidable debt this module's boundary design exists to prevent.
142///
143/// `pub(crate)`, not private: the [`release_task_slots`] test that seeds all
144/// three kinds for one task id lives in `task.rs`, alongside the two
145/// Buffer-only ones it completes, and needs to name a variant to call
146/// [`stash_for_test`] with (final review, I2). A placement choice, not a
147/// forced one -- the same coverage could instead have been written directly
148/// in this module's own tests, which already call [`stash`] without needing
149/// `Slot` visible outside it at all.
150#[derive(Copy, Clone)]
151pub(crate) enum Slot {
152    Buffer,
153    Array,
154    Message,
155}
156
157/// One task's three payload slots. Each field is a GC-rooted pointer, or 0.
158#[derive(Clone, Copy, Default)]
159struct Slots {
160    buffer: usize,
161    array: usize,
162    message: usize,
163}
164
165thread_local! {
166    /// Payload slots, per task, indexed by `slot_index`.
167    ///
168    /// `thread_local!` for the reason `task.rs`'s module doc gives for `TASKS`
169    /// and `QUEUE`: the GC's root table is per-thread, so a second thread
170    /// running Nova code would free objects the first still holds.
171    static SLOTS: RefCell<Vec<Slots>> = const { RefCell::new(Vec::new()) };
172}
173
174/// Allocate a GC-managed string for a slot payload.
175///
176/// A thin name for [`crate::gc_str`], reused rather than reproducing
177/// `NovaStr { len, ptr }`'s layout here a second time — precisely the drift
178/// class this module's boundary design exists to avoid.
179fn gc_message(s: &str) -> *mut NovaStr {
180    crate::gc_str(s)
181}
182
183/// Convert a task id into its `SLOTS` index. Shared by [`slot_index`] and
184/// [`release_task_slots`] so the `+ 1` offset is written in exactly one place
185/// (final review, M3 — it was previously duplicated between them).
186///
187/// `id + 1`, so index **0 stays the reserved no-task key** and is never a
188/// real task's own index. Both of today's callers already validate `id`
189/// against `TASKS` before reaching here (`task.rs`'s `release_internal`
190/// aborts and `take_output_internal` panics on an id that was never
191/// registered), so a negative `id` cannot reach this function today. Guarded
192/// anyway, for a future caller that might: `abort_with`, not a check that
193/// could panic, since this can run inside a generated poll boundary with no
194/// landing pads. Without the guard, `id as usize + 1` on a negative `id`
195/// is an overflow -- a debug-only panic, and a silent wrap onto the
196/// no-task index in release -- rather than a clean rejection.
197fn slot_index_for(id: i64) -> usize {
198    let Ok(id) = usize::try_from(id) else {
199        crate::task::abort_with("nova_rt_fs: task id must not be negative")
200    };
201    id + 1
202}
203
204/// The `SLOTS` index for the current task.
205///
206/// `id + 1`, so index **0 is the reserved no-task key**. Task ids are dense
207/// from zero (`poll_one` reads `tasks.get(id as usize)`), so a `Vec` gives O(1)
208/// access with no hashing and no per-call allocation.
209fn slot_index() -> usize {
210    crate::task::current_task().map_or(0, slot_index_for)
211}
212
213/// Run `f` against one field of the current task's slots, growing the table if
214/// this task has not been seen before.
215///
216/// **Panic-free by construction, because this runs inside a generated poll
217/// boundary with no landing pads.** `try_borrow_mut` rather than `borrow_mut`,
218/// and `get_mut` rather than `[i]`; both fall back to `abort_with`, which
219/// terminates without unwinding. The `get_mut` arm is unreachable — the resize
220/// immediately above guarantees `i < len` — and exists so the impossible case
221/// still cannot unwind.
222fn with_slot<R>(slot: Slot, f: impl FnOnce(&mut usize) -> R) -> R {
223    SLOTS.with(|cell| {
224        let Ok(mut slots) = cell.try_borrow_mut() else {
225            crate::task::abort_with("nova_rt_fs: payload slot table is already borrowed")
226        };
227        let i = slot_index();
228        if slots.len() <= i {
229            slots.resize(i + 1, Slots::default());
230        }
231        let Some(entry) = slots.get_mut(i) else {
232            crate::task::abort_with("nova_rt_fs: payload slot index out of range after resize")
233        };
234        let field = match slot {
235            Slot::Buffer => &mut entry.buffer,
236            Slot::Array => &mut entry.array,
237            Slot::Message => &mut entry.message,
238        };
239        f(field)
240    })
241}
242
243/// Register `ptr` as a GC root, then publish it in `slot`.
244///
245/// Rooting before publishing means a scan running between these two
246/// statements still finds the object through the root table — there is no
247/// instant where it is reachable by neither the slot nor a root. This now
248/// holds for the overwrite case too: releasing whatever `slot` already held
249/// happens first, via the same `take` a normal read uses, so a displaced
250/// pointer's root is never left dangling.
251///
252/// **Releases the slot's current occupant first (final review, I2).**
253/// Without this, stashing into an already-occupied slot would `add_root` the
254/// new pointer while leaving the displaced one's root registered forever —
255/// `take` is the only place that releases a *slot's* root (`stash_array`'s own
256/// `remove_root` call is a separate, self-contained build-time balance,
257/// finished before its block ever reaches a slot), and `take` can only see
258/// whichever pointer is *currently* published, so the one just overwritten
259/// would never be found and released. Not reachable through today's
260/// `std/fs` surface (every wrapper drains a slot before the next `stash`
261/// targeting it — see `a_stashed_string_is_rooted_until_it_is_taken`'s doc
262/// comment for why that is not itself enough to catch this), so this was
263/// latent rather than observed in any fixture; measured with a probe
264/// identical in shape to
265/// `stash_overwriting_an_occupied_slot_does_not_leak_the_displaced_root`
266/// below, minus this fix. `take`'s own contract (return `0` for an empty
267/// slot, otherwise the released pointer) makes discarding its result safe
268/// here: there is nothing to free by hand either way.
269///
270/// `pub(crate)`, not private: `std/fs` is not this function's only consumer.
271/// `crate::io` also stashes the standard streams' read payloads and write
272/// byte counts through it, and `crate::crypto` stashes digests, HMAC tags
273/// and random bytes the same way, rather than either module reproducing its
274/// root-balancing logic for itself.
275pub(crate) fn stash(slot: Slot, ptr: *mut NovaStr) {
276    take(slot);
277    gc::add_root(ptr as *mut u8);
278    with_slot(slot, |field| *field = ptr as usize);
279}
280
281/// Read and clear `slot`, releasing its root, and return what was there (`0`
282/// if the slot was empty).
283///
284/// **Ordered exactly as `task::take_output_internal` orders its matching
285/// `gc::remove_root`:** the pointer is read out of the slot and the slot is
286/// cleared *before* the root is released, so the add/remove stay balanced and
287/// the returned pointer is live throughout — rooted by the slot until the
288/// instant before this returns, then rooted by the caller's own frame like
289/// any other runtime return value.
290///
291/// **The `gc::remove_root` deliberately happens after the borrow is dropped**,
292/// not inside `with_slot`'s closure. Holding a `RefCell` borrow across a call
293/// into the collector would be a re-entrancy hazard for no benefit; the
294/// pointer is already out of the table and owned by this frame by then.
295///
296/// Private, not `pub(crate)` (final review, M4): every production caller is
297/// in this module -- `stash` below and the `nova_rt_fs_take_*` intrinsics --
298/// and `crate::io`'s production code only ever stashes (its payloads are
299/// meant for a future Nova caller to take via `nova_rt_fs_take_bytes`, not
300/// for `io.rs` itself to take back out). `crate::io`'s own *tests* are the
301/// one cross-module consumer, verifying what a stash left behind the same
302/// way this function's own tests here do; they reach this through
303/// [`take_for_test`] below instead of this function being widened just for
304/// them. `take` carries GC-root semantics (`remove_root`), so a
305/// broader-than-needed path to it is worth the extra indirection.
306fn take(slot: Slot) -> usize {
307    let ptr = with_slot(slot, |field| {
308        let ptr = *field;
309        *field = 0;
310        ptr
311    });
312    if ptr != 0 {
313        gc::remove_root(ptr as *mut u8);
314    }
315    ptr
316}
317
318/// Test-only cross-module accessor for [`take`], for `crate::io`'s own tests
319/// -- the same reason [`stash_for_test`] exists for [`stash`], and a
320/// narrower pattern than widening `take` itself would have been: every
321/// *production* call to [`take`] is inside this module, so it never needed
322/// more than module-private visibility.
323#[cfg(test)]
324pub(crate) fn take_for_test(slot: Slot) -> usize {
325    take(slot)
326}
327
328/// Release every payload root task `id` still holds, and clear its slots.
329///
330/// Called from `crate::task::release_internal` and `crate::task::take_output_internal` —
331/// the two places that release a task's *state* root. Keyed on `id` rather
332/// than [`slot_index`] precisely because those call sites do not run with
333/// `CURRENT` set to the task being released.
334///
335/// Idempotent, and silent for a task that never stashed anything: a task id
336/// past the end of the table simply has no slots.
337///
338/// Roots are released after the borrow is dropped, for the same reason
339/// [`take`] does it that way.
340pub(crate) fn release_task_slots(id: i64) {
341    let held = SLOTS.with(|cell| {
342        let Ok(mut slots) = cell.try_borrow_mut() else {
343            crate::task::abort_with("nova_rt_fs: payload slot table is already borrowed")
344        };
345        let index = slot_index_for(id);
346        match slots.get_mut(index) {
347            Some(entry) => {
348                let held = [entry.buffer, entry.array, entry.message];
349                *entry = Slots::default();
350                held
351            }
352            None => [0, 0, 0],
353        }
354    });
355    for ptr in held {
356        if ptr != 0 {
357            gc::remove_root(ptr as *mut u8);
358        }
359    }
360}
361
362/// Test-only: stash `ptr` into task `id`'s `slot`, without requiring
363/// `CURRENT` to already equal `id`.
364///
365/// `task.rs`'s own test module cannot reach `SLOTS` directly -- it is
366/// private to this module -- and even now that [`stash`] itself is
367/// `pub(crate)`, calling it directly still only ever targets *the current
368/// task's* slot (through `slot_index`), never an arbitrary one. This
369/// function needs a way to seed a payload for a task it registers through
370/// its own internals (`spawn_internal`/`nova_rt_task_spawn`) without
371/// driving a real poll, so it can call
372/// `release_internal`/`take_output_internal` directly and check whether
373/// either one actually drops the payload's root. Delegates
374/// to [`stash`] under a temporary [`crate::task::set_current_for_test`]
375/// override, restoring whatever `CURRENT` held beforehand rather than
376/// assuming it was `None`.
377///
378/// **Takes `slot` rather than hardcoding [`Slot::Buffer`] (final review,
379/// I2).** `release_task_slots` has three fields to release, and until this
380/// change every caller of this function seeded only `Buffer` -- widened
381/// only as far as pinning all three needed, not into a general-purpose
382/// helper.
383///
384/// `#[cfg(test)]` only: every caller of this function is an ordinary,
385/// cross-platform `task.rs` test, the same reasoning
386/// `set_current_for_test`'s own doc comment gives for itself.
387#[cfg(test)]
388pub(crate) fn stash_for_test(id: i64, slot: Slot, ptr: *mut NovaStr) {
389    let previous = crate::task::current_task();
390    crate::task::set_current_for_test(Some(id));
391    stash(slot, ptr);
392    crate::task::set_current_for_test(previous);
393}
394
395/// Map an `std::io::Error` to its status code, and stash its message.
396///
397/// Called on every failure path, so the message is always available to the
398/// wrapper that is about to build an `IoError`.
399///
400/// `pub(crate)`, not private: `crate::io` is a second consumer, mapping the
401/// standard streams' I/O errors through this same function rather than a
402/// second copy of the kind-to-status table.
403pub(crate) fn fail(e: &std::io::Error) -> i64 {
404    use std::io::ErrorKind;
405    stash(Slot::Message, gc_message(&e.to_string()));
406    match e.kind() {
407        ErrorKind::NotFound => NOT_FOUND,
408        ErrorKind::PermissionDenied => PERMISSION_DENIED,
409        ErrorKind::AlreadyExists => ALREADY_EXISTS,
410        ErrorKind::InvalidData => INVALID_DATA,
411        ErrorKind::Interrupted => INTERRUPTED,
412        ErrorKind::TimedOut => TIMED_OUT,
413        ErrorKind::ConnectionRefused => CONNECTION_REFUSED,
414        _ => OTHER,
415    }
416}
417
418/// Build a Nova `[String]` from `names` and stash it, GC-rooted.
419///
420/// **Reproduces the layout codegen emits for an array**: word 0 is the element
421/// count, elements follow at `8 + 8*i`, allocated scanned so the collector
422/// traces the `NovaStr` pointers inside. Getting this wrong is a silent
423/// miscompile rather than a failure, which is why `mod tests` asserts the
424/// tracked `(size, scan)` through `gc::object_info` and not merely the values
425/// read back — the same discipline `nova_rt_str_chars` carries.
426///
427/// Each element is rooted while it is being built, because the allocation of a
428/// later element can collect and an earlier one is then named only by a block
429/// that is itself not yet reachable from anywhere the collector scans.
430///
431/// **This is rooting the block, not each element — verified against `gc.rs`'s
432/// actual contract rather than assumed.** `block` is allocated with
433/// `scan = true` (the same flag `nova_rt_str_chars`'s array block uses), and
434/// `collect_with_roots`'s mark phase does not stop at a rooted object: every
435/// object it marks — reached directly as a root or transitively — is then
436/// traced if `scan` is true, meaning its words are read back and each one is
437/// itself passed through the identical range-based `mark_word` check a root
438/// gets. So once `block` is a root, every `NovaStr` pointer already written
439/// into its element words is found by that trace and kept alive too, the same
440/// way a rooted record keeps its fields alive without each field needing its
441/// own root. If `add_root`ing a block did NOT cause its contents to be
442/// traced, this function would need to root every element individually
443/// instead; it does not, because the property above holds.
444fn stash_array(names: &[String]) {
445    let n = names.len();
446    let block = gc::alloc(8 + 8 * n, true);
447    gc::add_root(block);
448    let words = block as *mut i64;
449    // SAFETY: `block` has `8 + 8*n` writable bytes, so word 0 and words
450    // `1..=n` are all in bounds.
451    unsafe { *words = n as i64 };
452    for (i, name) in names.iter().enumerate() {
453        let s = gc_message(name);
454        // SAFETY: same block; `i < n`.
455        unsafe { *words.add(1 + i) = s as i64 };
456    }
457    // The block is already rooted by `stash` below, so drop the build-time root
458    // to keep `add_root`/`remove_root` balanced.
459    gc::remove_root(block);
460    stash(Slot::Array, block as *mut NovaStr);
461}
462
463/// Read `path` as UTF-8. Returns a status; on `OK` the contents are waiting in
464/// `nova_rt_fs_take_string`.
465///
466/// Non-UTF-8 contents are `INVALID_DATA`: `std::fs::read_to_string` reports
467/// `ErrorKind::InvalidData` for them, which is exactly the case that motivated
468/// adding that kind (see docs/adr/0011-io-error-kinds.md).
469///
470/// # Safety
471/// `path` must point to a live `NovaStr`.
472#[no_mangle]
473pub unsafe extern "C" fn nova_rt_fs_read_to_string(path: *const NovaStr) -> i64 {
474    // SAFETY: forwarding this function's own contract.
475    let p = unsafe { crate::as_str(path) };
476    match std::fs::read_to_string(p) {
477        Ok(s) => {
478            stash(Slot::Buffer, gc_message(&s));
479            OK
480        }
481        Err(e) => fail(&e),
482    }
483}
484
485/// Write `content` to `path`, truncating an existing file.
486///
487/// # Safety
488/// Both arguments must point to live `NovaStr`s.
489#[no_mangle]
490pub unsafe extern "C" fn nova_rt_fs_write_string(
491    path: *const NovaStr,
492    content: *const NovaStr,
493) -> i64 {
494    // SAFETY: forwarding this function's own contract.
495    let (p, c) = unsafe { (crate::as_str(path), crate::as_str(content)) };
496    match std::fs::write(p, c) {
497        Ok(()) => OK,
498        Err(e) => fail(&e),
499    }
500}
501
502/// Take the pending payload string. Returns an empty string if nothing is
503/// pending, which a correct wrapper never asks for.
504#[no_mangle]
505pub extern "C" fn nova_rt_fs_take_string() -> *mut NovaStr {
506    match take(Slot::Buffer) {
507        0 => gc_message(""),
508        p => p as *mut NovaStr,
509    }
510}
511
512/// Read `path`'s bytes. Returns a status; on `OK` the contents are waiting in
513/// `nova_rt_fs_take_bytes`.
514///
515/// Unlike `nova_rt_fs_read_to_string` this cannot produce `INVALID_DATA`: there
516/// is no encoding to violate. Any status other than `OK` is a real I/O failure.
517///
518/// # Safety
519/// `path` must point to a live `NovaStr`.
520#[no_mangle]
521pub unsafe extern "C" fn nova_rt_fs_read(path: *const NovaStr) -> i64 {
522    // SAFETY: forwarding this function's own contract.
523    let p = unsafe { crate::as_str(path) };
524    match std::fs::read(p) {
525        Ok(bytes) => {
526            stash(Slot::Buffer, crate::bytes::gc_bytes(&bytes));
527            OK
528        }
529        Err(e) => fail(&e),
530    }
531}
532
533/// Write `content`'s bytes to `path`, truncating an existing file.
534///
535/// # Safety
536/// Both arguments must point to live `NovaStr`s.
537#[no_mangle]
538pub unsafe extern "C" fn nova_rt_fs_write(path: *const NovaStr, content: *const NovaStr) -> i64 {
539    // SAFETY: forwarding this function's own contract.
540    let (p, c) = unsafe { (crate::as_str(path), crate::bytes::as_bytes(content)) };
541    match std::fs::write(p, c) {
542        Ok(()) => OK,
543        Err(e) => fail(&e),
544    }
545}
546
547/// Take the pending payload as `Bytes`. Returns an empty `Bytes` if nothing is
548/// pending, which a correct wrapper never asks for.
549///
550/// Mirrors [`nova_rt_fs_take_string`], reading the same
551/// [`Slot::Buffer`] -- the payload left there by a successful
552/// [`nova_rt_fs_read`], never interpreted as UTF-8.
553///
554/// **The `fs_` prefix is now historical.** Since increment 3a this reads a
555/// general per-task slot, not an fs-specific one, and `crate::io`'s stream
556/// intrinsics stash exactly this shape of payload here too -- both a read
557/// buffer and, for a write, its byte count encoded as bytes.
558/// `std/io/lib.nova`'s `read_stdin`, `write_stdout` and `write_stderr` are
559/// that outside-`std/fs` caller now, collecting through this same taker
560/// rather than a second one.
561#[no_mangle]
562pub extern "C" fn nova_rt_fs_take_bytes() -> *mut NovaStr {
563    match take(Slot::Buffer) {
564        0 => crate::bytes::gc_bytes(&[]),
565        p => p as *mut NovaStr,
566    }
567}
568
569/// Take the pending error message.
570///
571/// **The `fs_` prefix is now historical**, for the same reason
572/// [`nova_rt_fs_take_bytes`]'s doc comment gives: [`fail`] stashes into the
573/// same per-task [`Slot::Message`] regardless of which module's intrinsic
574/// called it. Every fallible wrapper in `std/io/lib.nova` --
575/// `read_stdin`, `write_stdout`, `write_stderr`, `flush_stdout` and
576/// `flush_stderr` -- reads its own error message back through this same
577/// taker now, alongside `std/fs`'s own wrappers.
578#[no_mangle]
579pub extern "C" fn nova_rt_fs_last_error_message() -> *mut NovaStr {
580    match take(Slot::Message) {
581        0 => gc_message(""),
582        p => p as *mut NovaStr,
583    }
584}
585
586/// The OS temporary-directory path, per `std::env::temp_dir()`.
587///
588/// `to_string_lossy`, not a fallible conversion: a Nova `String` is UTF-8
589/// while a Windows path is UTF-16, so a path containing an unpaired
590/// surrogate cannot round-trip exactly. There is no `IoError` to report that
591/// through here -- nothing failed at the OS level, the path is simply
592/// unrepresentable exactly -- and this path is exotic enough in practice
593/// that a usable (if occasionally imprecise) string beats an operation with
594/// no way to signal the problem.
595#[no_mangle]
596pub extern "C" fn nova_rt_fs_temp_dir() -> *mut NovaStr {
597    gc_message(&std::env::temp_dir().to_string_lossy())
598}
599
600/// Whether `path` exists. Cannot distinguish absent from unreadable, because
601/// `nova-spec/20-STDLIB.md` §5 gives `exists` a `Bool` return rather than a
602/// `Result`; that is the spec's choice and is left alone.
603///
604/// # Safety
605/// `path` must point to a live `NovaStr`.
606#[no_mangle]
607pub unsafe extern "C" fn nova_rt_fs_exists(path: *const NovaStr) -> i8 {
608    // SAFETY: forwarding this function's own contract.
609    let p = unsafe { crate::as_str(path) };
610    i8::from(std::path::Path::new(p).exists())
611}
612
613/// Create the directory `path`. Its parent must exist; an existing `path` is
614/// `ALREADY_EXISTS`.
615///
616/// # Safety
617/// `path` must point to a live `NovaStr`.
618#[no_mangle]
619pub unsafe extern "C" fn nova_rt_fs_create_dir(path: *const NovaStr) -> i64 {
620    // SAFETY: forwarding this function's own contract.
621    let p = unsafe { crate::as_str(path) };
622    match std::fs::create_dir(p) {
623        Ok(()) => OK,
624        Err(e) => fail(&e),
625    }
626}
627
628/// Create the directory `path`, and any missing parent directories along the
629/// way. Unlike `create_dir`, an existing directory at `path` is success, not
630/// `ALREADY_EXISTS` -- `std::fs::create_dir_all`'s own behaviour, carried
631/// through unchanged rather than papered over.
632///
633/// # Safety
634/// `path` must point to a live `NovaStr`.
635#[no_mangle]
636pub unsafe extern "C" fn nova_rt_fs_create_dir_all(path: *const NovaStr) -> i64 {
637    // SAFETY: forwarding this function's own contract.
638    let p = unsafe { crate::as_str(path) };
639    match std::fs::create_dir_all(p) {
640        Ok(()) => OK,
641        Err(e) => fail(&e),
642    }
643}
644
645/// Remove the file `path`.
646///
647/// # Safety
648/// `path` must point to a live `NovaStr`.
649#[no_mangle]
650pub unsafe extern "C" fn nova_rt_fs_remove_file(path: *const NovaStr) -> i64 {
651    // SAFETY: forwarding this function's own contract.
652    let p = unsafe { crate::as_str(path) };
653    match std::fs::remove_file(p) {
654        Ok(()) => OK,
655        Err(e) => fail(&e),
656    }
657}
658
659/// Remove the directory `path` and everything under it.
660///
661/// # Safety
662/// `path` must point to a live `NovaStr`.
663#[no_mangle]
664pub unsafe extern "C" fn nova_rt_fs_remove_dir_all(path: *const NovaStr) -> i64 {
665    // SAFETY: forwarding this function's own contract.
666    let p = unsafe { crate::as_str(path) };
667    match std::fs::remove_dir_all(p) {
668        Ok(()) => OK,
669        Err(e) => fail(&e),
670    }
671}
672
673/// List `path`'s entry names, sorted. On `OK` the names are waiting in
674/// `nova_rt_fs_take_string_array`.
675///
676/// **Sorted in the runtime deliberately.** Directory order is unspecified by
677/// every OS, so unsorted output would make each fixture platform-dependent.
678///
679/// # Safety
680/// `path` must point to a live `NovaStr`.
681#[no_mangle]
682pub unsafe extern "C" fn nova_rt_fs_read_dir(path: *const NovaStr) -> i64 {
683    // SAFETY: forwarding this function's own contract.
684    let p = unsafe { crate::as_str(path) };
685    let iter = match std::fs::read_dir(p) {
686        Ok(it) => it,
687        Err(e) => return fail(&e),
688    };
689    let mut names: Vec<String> = Vec::new();
690    for entry in iter {
691        match entry {
692            Ok(e) => names.push(e.file_name().to_string_lossy().into_owned()),
693            Err(e) => return fail(&e),
694        }
695    }
696    names.sort();
697    stash_array(&names);
698    OK
699}
700
701/// Take the array payload staged by a successful `nova_rt_fs_read_dir`.
702///
703/// Mirrors [`nova_rt_fs_take_string`], with the one difference forced by there
704/// being no such thing as a null Nova array: an empty slot (`0`) yields a
705/// fresh, well-formed zero-length array here -- the same `{ len: 0 }` shape
706/// `nova_rt_str_chars` produces for an empty string -- rather than a null
707/// pointer, which a correct wrapper would then have to null-check for no
708/// reason (a correct wrapper never asks for this when nothing is pending,
709/// exactly as `nova_rt_fs_take_string`'s own doc comment notes).
710#[no_mangle]
711pub extern "C" fn nova_rt_fs_take_string_array() -> *mut u8 {
712    match take(Slot::Array) {
713        0 => {
714            let block = gc::alloc(8, true);
715            let words = block as *mut i64;
716            // SAFETY: `block` is 8 bytes, so word 0 is in bounds.
717            unsafe { *words = 0 };
718            block
719        }
720        p => p as *mut u8,
721    }
722}
723
724/// What `path` is: 0 = metadata unavailable (absent, or unreadable), 1 file,
725/// 2 directory.
726///
727/// One call rather than separate `is_file`/`is_dir` intrinsics, so a `DirEntry`
728/// costs one syscall instead of two and the two answers cannot disagree.
729///
730/// **The convention for an entry that is neither, stated here because the code
731/// below does not spell it out: it is classified `1` (file).** The check is
732/// `is_dir()`, with everything else that exists falling to the `else` arm, so
733/// a FIFO, Unix domain socket, or device node -- a filesystem entry that is
734/// genuinely neither a plain file nor a directory -- is `1`, not some third
735/// code. This is a stated convention, not a measurement: nothing in this
736/// codebase constructs such an entry to observe it against, and on Windows
737/// the class is structurally unreachable through this function's `metadata`
738/// call. `tests/runtime/fs_read_dir.nova`'s fixture, the one test that drives
739/// this function, contains only a plain file and a subdirectory -- it pins
740/// the two-way split, and says nothing about the third case, which is why
741/// this paragraph exists instead of a test.
742///
743/// # Safety
744/// `path` must point to a live `NovaStr`.
745#[no_mangle]
746pub unsafe extern "C" fn nova_rt_fs_kind(path: *const NovaStr) -> i64 {
747    // SAFETY: forwarding this function's own contract.
748    let p = unsafe { crate::as_str(path) };
749    let meta = match std::fs::metadata(p) {
750        Ok(m) => m,
751        Err(_) => return 0,
752    };
753    if meta.is_dir() {
754        2
755    } else {
756        1
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    /// **Measured, not assumed -- on Windows only.** The design spec's note
765    /// on `remove_dir_all` flags Windows-versus-POSIX divergence for a
766    /// read-only entry as an expectation, not something anyone had actually
767    /// run. Measured here, against `nova_rt_fs_remove_dir_all` itself rather
768    /// than bare `std::fs`: on Windows, a read-only file nested inside the
769    /// tree does not stop the removal -- the status is `OK` and the
770    /// directory is gone afterward, the same as for an ordinary file.
771    /// (Windows' own `DeleteFile` denies access to a read-only attribute;
772    /// whatever compensates -- clearing the attribute first, or a different
773    /// removal path entirely -- lives in the standard library this function
774    /// calls straight into, not in this crate, so it is not this crate's to
775    /// document further than "observed".)
776    ///
777    /// **`#[cfg(windows)]` because Windows is the only platform this was
778    /// actually run on.** `.github/workflows/ci.yml` runs ubuntu, windows and
779    /// macos, so leaving this ungated would assert a POSIX result nobody
780    /// measured. There is a plausible reason POSIX would behave the same --
781    /// unlinking a directory entry is governed by the *directory's* write
782    /// permission, not the target file's own mode bits, so a read-only file
783    /// should not by itself block removal -- but that is exactly a plausible
784    /// reason, not a measurement, and this project keeps getting burned by
785    /// treating the two as equivalent. Recorded here as the *expectation*
786    /// for POSIX, explicitly labelled as unmeasured -- the same "observed,
787    /// not explained" treatment the Windows mechanism gets above. See the
788    /// task report this landed with for the transcript this was first
789    /// observed in.
790    #[cfg(windows)]
791    #[test]
792    fn remove_dir_all_is_not_stopped_by_a_read_only_entry() {
793        let dir =
794            std::env::temp_dir().join(format!("nova-rt-fs-readonly-probe-{}", std::process::id()));
795        let _ = std::fs::remove_dir_all(&dir);
796        std::fs::create_dir_all(&dir).expect("probe dir");
797        let file = dir.join("locked.txt");
798        std::fs::write(&file, "x").expect("probe file");
799        let mut perms = std::fs::metadata(&file)
800            .expect("probe metadata")
801            .permissions();
802        perms.set_readonly(true);
803        std::fs::set_permissions(&file, perms).expect("set readonly");
804
805        let path_str = gc_message(&dir.to_string_lossy());
806        // SAFETY: `path_str` was just allocated above and is still live.
807        let status = unsafe { nova_rt_fs_remove_dir_all(path_str) };
808        let dir_gone_after_removal = !dir.exists();
809
810        // Best-effort cleanup in case the removal above did *not* clear the
811        // read-only file (i.e. the assertions below are about to fail): rely
812        // on the exact same call this test measures, not a hand-rolled
813        // `set_readonly(false)` -- which clippy's
814        // `permissions_set_readonly_false` rightly flags as unsafe to do
815        // generically, since on Unix it makes the file world-writable rather
816        // than merely owner-writable. If plain `remove_dir_all` cannot clear
817        // it either, there is nothing more this cleanup should attempt.
818        let _ = std::fs::remove_dir_all(&dir);
819
820        assert_eq!(
821            status, OK,
822            "measured on this platform: a read-only entry did not stop removal"
823        );
824        assert!(
825            dir_gone_after_removal,
826            "measured on this platform: the directory was fully removed"
827        );
828    }
829
830    /// Pins the spec's "these `async fn`s never suspend" property.
831    ///
832    /// **Decided before execution.** The spec asks for a test that `PARKED` is
833    /// empty after a `std/fs` call, but `PARKED` is private to `crate::task` and
834    /// no accessor exists, so the property is pinned at its source instead:
835    /// nothing in this module may register a park. That is strictly what
836    /// matters — a `std/fs` intrinsic can only reach the park set by calling
837    /// `stage_park`.
838    ///
839    /// Self-referential, like `every_rt_func_is_declared_with_its_real_signature`
840    /// elsewhere in this workspace, and carrying the same weakness: it checks
841    /// the text of this file, not the behaviour of a running program. It would
842    /// miss parking reached by some route other than a literal call. Accepted
843    /// because the alternative is test-only surface in a module this branch
844    /// does not otherwise touch.
845    ///
846    /// **What actually keeps a split-on-a-literal guard sound, stated once
847    /// here for this guard, its sibling below, and `bytes.rs`'s equivalent
848    /// (final review, I1, correcting an overclaim this comment used to make):
849    /// the property is that the *first* occurrence of the split literal is
850    /// the real boundary -- not that the literal is unique in the file.** It
851    /// is not unique: this explanation, the sibling guard below, and each
852    /// guard's own split call all contain the same text, and that is
853    /// harmless only because every occurrence of it comes *after* the real
854    /// one, however many there turn out to be. What would not be harmless is
855    /// an *earlier* one -- a production doc comment quoting the exact split
856    /// text before the real module declaration would silently truncate the
857    /// scan right there, and **this class of guard fails open**: it passes
858    /// when it covers nothing, with no test failing to say so. The ceiling
859    /// this stays honest about, for the same reason: a substring scan cannot
860    /// see past a rewrite that reaches equivalent code a different way --
861    /// fully-qualified syntax (`RefCell::borrow_mut(&*cell)` instead of
862    /// `cell.borrow_mut()`), an aliased import, or indexing spelled other
863    /// than literal `[i]`. Not a plausible thing for anyone here to write,
864    /// but it is the honest limit of what this class of guard can promise.
865    #[test]
866    fn no_filesystem_intrinsic_registers_a_park() {
867        let source = include_str!("fs.rs");
868        // Skip the whole `mod tests` block, not just this function's body:
869        // this test's *own* doc comment above names `stage_park` in prose,
870        // and splitting on the function name alone would leave that doc
871        // comment in the scanned half, failing even when no real intrinsic
872        // parks. Splits on the module declaration's own text, `mod tests {`,
873        // rather than the bare `#[cfg(test)]` attribute one line above it --
874        // `stash_for_test`'s doc comment above quotes that exact attribute
875        // text in prose ("`#[cfg(test)]` only, not also..."), an *earlier*
876        // occurrence of the same substring than the real attribute, which
877        // silently cut this scan short right there instead of at the real
878        // module boundary, for as long as that comment has existed (found
879        // while writing `no_slot_access_can_panic_on_a_borrow` below, which
880        // would have inherited the identical defect from the same pattern).
881        // See this function's own doc comment above for the property that
882        // actually keeps this sound, now that "unique" has been corrected.
883        let code = source.split("mod tests {").next().unwrap_or(source);
884        assert!(
885            !code.contains("stage_park"),
886            "a std/fs intrinsic must not park: these async fns run synchronously \
887             inside the poll, and parking here without the poller work would \
888             suspend a task nothing can wake"
889        );
890    }
891
892    /// Every `SLOTS` access in production code is fallible-borrow, never
893    /// `borrow_mut`, and this file's production half is also free of the
894    /// other panic sources this workspace watches for.
895    ///
896    /// A `RefCell` borrow panic in this module would cross a generated poll
897    /// boundary, where there are no landing pads — the one hazard the
898    /// per-task table introduced that the three `Cell`s it replaced could
899    /// not have. Pinned at its source rather than by a fixture, so it
900    /// covers every future access without a recount.
901    ///
902    /// **Needle list widened toward `bytes.rs`'s, with one deliberate
903    /// omission (final review, M1).** `bytes.rs`'s sibling guard
904    /// (`no_bytes_intrinsic_can_panic`) also needles the bare word
905    /// `RefCell`, sound there because that module never declares one. This
906    /// module's design *is* a `RefCell` -- `SLOTS`, declared a few dozen
907    /// lines above, correctly and unavoidably -- so that needle was checked
908    /// and would fail immediately here, not merely pass with less room to
909    /// regress; it is deliberately left out rather than forgotten.
910    /// `unwrap()`, `.expect(`, `panic!` and `format!` were checked the same
911    /// way and are genuinely absent from this file's production half today,
912    /// so adding them costs nothing now and covers more later. **Indexing
913    /// (`[i]`) stays outside what this guard can see** -- a substring scan
914    /// cannot distinguish a safe, already-bounds-checked `[i]` from a
915    /// dangerous one -- so that half of panic-freedom is held by review, not
916    /// mechanically; `CHANGELOG.md`'s entry for this module says so now
917    /// instead of overclaiming it.
918    ///
919    /// Scans only the part of this file before its own `mod tests` block,
920    /// for the same reason `no_filesystem_intrinsic_registers_a_park` does
921    /// and, since Task 3, in the same corrected way -- see that test's own
922    /// doc comment for why the split is on the literal `mod tests {` rather
923    /// than the bare `#[cfg(test)]` attribute above it, for the property
924    /// that actually keeps the split sound, and for the ceiling on what a
925    /// guard shaped like this one can ever promise.
926    #[test]
927    fn no_slot_access_can_panic_on_a_borrow() {
928        let source = include_str!("fs.rs");
929        let production = source.split("mod tests {").next().unwrap_or(source);
930        for needle in [
931            ".borrow_mut()",
932            ".borrow()",
933            "unwrap()",
934            ".expect(",
935            "panic!",
936            "format!",
937        ] {
938            assert!(
939                !production.contains(needle),
940                "a std/fs intrinsic must not panic: `{needle}` found in this file's \
941                 production code, which is reachable from a generated poll boundary \
942                 with no landing pad to unwind through"
943            );
944        }
945    }
946
947    /// `stash` registers exactly one root for its pointer, and `take` releases
948    /// exactly that root before returning it — the pairing this module's
949    /// whole safety claim rests on, since a collection between the two is
950    /// what the payload must survive.
951    ///
952    /// **Decided during verification, deviating from the plan.** The plan's
953    /// version of this test allocated 2000 "churn" strings after stashing and
954    /// then asserted the payload's bytes survived, relying on a real,
955    /// conservative-scan-triggered collection to actually run. Applying the
956    /// mutation this test exists to catch (deleting the `gc::add_root` call
957    /// in `stash`) and re-running proved that version does not discriminate:
958    /// it kept passing even under `NOVA_GC_STRESS=1` (a collection on every
959    /// allocation) and even with the stash moved into its own
960    /// `#[inline(never)]` frame. That is this workspace's own documented
961    /// failure mode — `docs/adr/0010-conservative-scan-root-test-gating.md`
962    /// records the conservative stack scanner intermittently retaining an
963    /// object via a stale word in an already-returned frame, standing in for
964    /// the registration a test like this means to check — which is exactly
965    /// why the 8 tests that ran into it are `#[ignore]`d rather than fixed by
966    /// raising a loop count further. `gc::root_count` sidesteps the scanner
967    /// entirely by reading the root registry directly, deterministically and
968    /// on every platform; `task.rs`'s
969    /// `a_completed_tasks_state_stays_rooted_until_its_output_is_taken` pins
970    /// the identical kind of add_root/remove_root pairing the same way, for
971    /// the same reason.
972    ///
973    /// **Also pins that `take` clears the field, not only that it releases
974    /// the root (final review, I3).** Nothing previously read a slot twice or
975    /// asserted a drained one reads back empty. Left undone, a second
976    /// `stash` into the same slot -- or a `release_task_slots` for the same
977    /// task -- would issue a second `gc::remove_root` for an address whose
978    /// first registration is already gone, which can cancel a different,
979    /// live registration once the collector reuses that address.
980    #[test]
981    fn a_stashed_string_is_rooted_until_it_is_taken() {
982        let p = gc_message("payload");
983        let addr = p as usize;
984        stash(Slot::Buffer, p);
985        assert_eq!(
986            gc::root_count(addr),
987            1,
988            "stash must register exactly one root for its pointer, or a \
989             collection between stash and take could free the payload"
990        );
991
992        let taken = nova_rt_fs_take_string();
993        assert_eq!(taken as usize, addr, "take must return the stashed pointer");
994        assert_eq!(
995            gc::root_count(addr),
996            0,
997            "take must release exactly the one root stash registered, or \
998             add_root/remove_root drift out of balance"
999        );
1000        // SAFETY: `taken`'s root was only just released, one statement
1001        // above, and nothing has allocated since -- so despite no longer
1002        // being explicitly rooted, it has not yet been swept.
1003        assert_eq!(unsafe { crate::as_str(taken) }, "payload");
1004        assert_eq!(take(Slot::Buffer), 0, "take must clear the slot");
1005    }
1006
1007    /// `stash` must not leak the root of whatever a slot already held when a
1008    /// second pointer overwrites it (final review, I2). Fails without the
1009    /// `take(slot)` line at the top of `stash`: measured by temporarily
1010    /// deleting it, which reproduces the review's own probe exactly --
1011    /// `a_root` stayed at `1` after `b` was stashed over it, instead of
1012    /// dropping to `0`.
1013    ///
1014    /// Not reachable through today's `std/fs` wrappers -- every one drains a
1015    /// slot before the next `stash` targeting it -- so this is a property of
1016    /// `stash` in isolation, exercised directly rather than through a Nova
1017    /// fixture, the same way `a_stashed_string_is_rooted_until_it_is_taken`
1018    /// is.
1019    #[test]
1020    fn stash_overwriting_an_occupied_slot_does_not_leak_the_displaced_root() {
1021        let a = gc_message("first");
1022        let b = gc_message("second");
1023        let (a_addr, b_addr) = (a as usize, b as usize);
1024
1025        stash(Slot::Buffer, a);
1026        assert_eq!(
1027            gc::root_count(a_addr),
1028            1,
1029            "the first stash roots its pointer"
1030        );
1031
1032        stash(Slot::Buffer, b);
1033        assert_eq!(
1034            gc::root_count(a_addr),
1035            0,
1036            "stashing a second pointer into an occupied slot must release the \
1037             first one's root -- otherwise it is pinned for the thread's \
1038             lifetime, since `take` is the only place that releases a slot's \
1039             root and can only ever see the slot's *current* occupant"
1040        );
1041        assert_eq!(
1042            gc::root_count(b_addr),
1043            1,
1044            "the second stash roots its pointer"
1045        );
1046
1047        let taken = take(Slot::Buffer);
1048        assert_eq!(
1049            taken, b_addr,
1050            "take returns the most recently stashed pointer"
1051        );
1052        assert_eq!(
1053            gc::root_count(b_addr),
1054            0,
1055            "take releases the surviving root"
1056        );
1057    }
1058
1059    /// **Reproduces the exact array layout codegen emits**, the same
1060    /// discipline `nova_rt_str_chars`'s own layout test (`nova-runtime`'s
1061    /// `lib.rs`) uses: reading back the words `stash_array` wrote cannot by
1062    /// itself distinguish a correctly sized block from one that merely has
1063    /// enough allocator slop past a *wrong* declared size for these three
1064    /// words to still land in live memory, and it cannot observe the `scan`
1065    /// flag at all -- that only changes GC behaviour, not what is readable.
1066    /// Asserting `gc::object_info` alongside the values closes both holes.
1067    #[test]
1068    fn a_stashed_array_has_the_layout_the_abi_declares() {
1069        let names = vec!["a".to_string(), "bb".to_string()];
1070        stash_array(&names);
1071        let block = take(Slot::Array);
1072        assert_eq!(
1073            gc::object_info(block),
1074            Some((8 + 8 * 2, true)),
1075            "an array block is a length word plus one word per element, scanned"
1076        );
1077        let words = block as *mut i64;
1078        // SAFETY: the block is `8 + 8*2` bytes, so these three words are in
1079        // bounds, and reading it here is safe for the same reason
1080        // `a_stashed_string_is_rooted_until_it_is_taken` reads its own `taken`
1081        // after release: nothing has allocated since `take` ran one statement
1082        // above, so nothing has swept it yet.
1083        unsafe {
1084            assert_eq!(*words, 2);
1085            assert_eq!(crate::as_str(*words.add(1) as *const NovaStr), "a");
1086            assert_eq!(crate::as_str(*words.add(2) as *const NovaStr), "bb");
1087        }
1088    }
1089
1090    /// Two tasks stashing between one task's stash and its take must not collide.
1091    ///
1092    /// This is the defect the per-task table exists to prevent, and it cannot be
1093    /// reached from Nova today: `std/fs`'s wrappers are straight-line, so nothing
1094    /// runs between a stash and its take. Increment 4's poller inserts exactly
1095    /// that gap, which is why the interleaving is built here by hand instead.
1096    ///
1097    /// **Fails against the pre-change thread-local slots**, where task B's stash
1098    /// releases task A's root and overwrites the one shared slot, so A's take
1099    /// returns B's pointer. That is what earns this test.
1100    #[test]
1101    fn a_stash_is_private_to_the_task_that_made_it() {
1102        let a = crate::gc_str("task-a-payload");
1103        let b = crate::gc_str("task-b-payload");
1104
1105        crate::task::set_current_for_test(Some(0));
1106        stash(Slot::Buffer, a);
1107
1108        crate::task::set_current_for_test(Some(1));
1109        stash(Slot::Buffer, b);
1110
1111        crate::task::set_current_for_test(Some(0));
1112        let got = take(Slot::Buffer);
1113        assert_eq!(
1114            got, a as usize,
1115            "task 0 must read back its own payload, not task 1's"
1116        );
1117
1118        crate::task::set_current_for_test(Some(1));
1119        assert_eq!(
1120            take(Slot::Buffer),
1121            b as usize,
1122            "task 1's payload must still be there, undisturbed"
1123        );
1124        crate::task::set_current_for_test(None);
1125    }
1126
1127    /// "No task" is a key, not a special case, and it does not collide with task 0.
1128    ///
1129    /// Kills the plausible mistake of indexing by `id as usize` instead of
1130    /// `id as usize + 1`, which would map task 0 onto the no-task slot. `fs.rs`'s
1131    /// other unit tests all run with `CURRENT == None`, so this is the path they
1132    /// take.
1133    #[test]
1134    fn the_no_task_key_does_not_collide_with_task_zero() {
1135        let none_payload = crate::gc_str("no-task");
1136        let zero_payload = crate::gc_str("task-zero");
1137
1138        crate::task::set_current_for_test(None);
1139        stash(Slot::Buffer, none_payload);
1140
1141        crate::task::set_current_for_test(Some(0));
1142        stash(Slot::Buffer, zero_payload);
1143
1144        crate::task::set_current_for_test(None);
1145        assert_eq!(
1146            take(Slot::Buffer),
1147            none_payload as usize,
1148            "the no-task slot must not be task 0's slot"
1149        );
1150
1151        crate::task::set_current_for_test(Some(0));
1152        assert_eq!(take(Slot::Buffer), zero_payload as usize);
1153        crate::task::set_current_for_test(None);
1154    }
1155
1156    /// A task's unread payload is released when its state root is.
1157    ///
1158    /// `task.rs` releases a task's state root in `release_internal` and
1159    /// `take_output_internal` — through `release_internal` from `poll_one` at
1160    /// completion for every task but `block_on`'s root, and at
1161    /// `take_output_internal` for that root (see `poll_one`'s and
1162    /// `take_output_internal`'s own doc comments in `task.rs`). Payload
1163    /// release hangs off the same two points so payload lifetime follows the
1164    /// policy `task.rs` already owns rather than a second one.
1165    ///
1166    /// Uses `gc::root_count` rather than asserting the object is collected: per
1167    /// ADR 0010, a churn-loop test asserting survival cannot discriminate on this
1168    /// platform. This proves bookkeeping, not survival, and claims only that.
1169    #[test]
1170    fn releasing_a_tasks_slots_drops_the_roots_it_held() {
1171        let payload = crate::gc_str("never-read");
1172        let addr = payload as usize;
1173
1174        crate::task::set_current_for_test(Some(7));
1175        stash(Slot::Buffer, payload);
1176        assert_eq!(gc::root_count(addr), 1, "the stash roots its pointer");
1177
1178        crate::task::set_current_for_test(None);
1179        release_task_slots(7);
1180        assert_eq!(
1181            gc::root_count(addr),
1182            0,
1183            "releasing task 7's slots must release the root it held"
1184        );
1185
1186        // Idempotent: a second release must not double-remove or abort.
1187        release_task_slots(7);
1188        assert_eq!(gc::root_count(addr), 0);
1189    }
1190
1191    /// Releasing one task's slots leaves another task's alone.
1192    #[test]
1193    fn releasing_one_tasks_slots_leaves_another_tasks_intact() {
1194        let keep = crate::gc_str("keep-me");
1195        let keep_addr = keep as usize;
1196
1197        crate::task::set_current_for_test(Some(3));
1198        stash(Slot::Message, keep);
1199        release_task_slots(4);
1200        assert_eq!(
1201            gc::root_count(keep_addr),
1202            1,
1203            "task 4's release must not touch task 3's slots"
1204        );
1205
1206        crate::task::set_current_for_test(Some(3));
1207        assert_eq!(take(Slot::Message), keep as usize);
1208        crate::task::set_current_for_test(None);
1209    }
1210}