Skip to main content

nova_runtime/
bytes.rs

1//! Byte-buffer intrinsics for `std/bytes`.
2//!
3//! # Representation, and why there is no `NovaBytes` struct
4//!
5//! A `Bytes` value is a [`crate::NovaStr`] — one GC **leaf** object, a
6//! `{len, ptr}` header followed inline by the bytes `ptr` points at. That is
7//! not an approximation: `String` and `Bytes` have the *same* layout and
8//! differ only in that `String` carries a UTF-8 guarantee, which lives in the
9//! type system rather than the representation.
10//!
11//! **A second struct with the same fields would be a second copy of a layout,
12//! which is the drift class this project has already shipped a miscompile
13//! from.** So there is one struct and one set of allocation helpers, and the
14//! distinction is enforced by `hir::Ty`, not by Rust.
15//!
16//! # Panic-freedom
17//!
18//! Some of these are reachable from inside an `async fn`'s generated `$poll`,
19//! which has no landing pads, so nothing here may panic. Where a caller error
20//! must be rejected, it aborts — `abort_with` does not unwind.
21
22use crate::NovaStr;
23
24/// Store `bytes` as a GC-managed `Bytes` value: one **leaf** object holding
25/// the header and, inline after it, a copy of the payload.
26///
27/// The sibling of `crate::gc_str`, differing only in taking `&[u8]` rather than
28/// `&str`. Both build through [`crate::alloc_str_object`], so the layout is
29/// identical by construction.
30///
31/// GC safety: three callers (`nova_rt_bytes_from_string`,
32/// `nova_rt_bytes_to_string_unchecked`, `nova_rt_bytes_slice`) pass a slice of
33/// a live GC string, read here after this function's allocation. It stays
34/// alive across that allocation because the caller's slice pointer is a root
35/// and marking is range-based (`gc.rs`'s module doc comment).
36pub(crate) fn gc_bytes(bytes: &[u8]) -> *mut NovaStr {
37    let (node, dst) = crate::alloc_str_object(bytes.len());
38    // SAFETY: `alloc_str_object` reserved `bytes.len()` writable bytes at
39    // `dst`, inside a fresh object that cannot overlap `bytes`.
40    unsafe { std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst, bytes.len()) };
41    node
42}
43
44/// The bytes of `b` as a slice.
45///
46/// # Safety
47/// `b` must point to a live `NovaStr`.
48pub(crate) unsafe fn as_bytes<'a>(b: *const NovaStr) -> &'a [u8] {
49    // SAFETY: forwarding this function's own contract; `{len, ptr}` describes a
50    // valid buffer for any `NovaStr` this module or `gc_str` produced.
51    unsafe { std::slice::from_raw_parts((*b).ptr, (*b).len as usize) }
52}
53
54/// The byte length of `b`. Not a character count — `Bytes` has no encoding.
55///
56/// # Safety
57/// `b` must point to a live `NovaStr`.
58#[no_mangle]
59pub unsafe extern "C" fn nova_rt_bytes_len(b: *const NovaStr) -> i64 {
60    // SAFETY: forwarding this function's own contract.
61    unsafe { (*b).len as i64 }
62}
63
64/// A `Bytes` holding `s`'s UTF-8 bytes.
65///
66/// # Safety
67/// `s` must point to a live `NovaStr`.
68#[no_mangle]
69pub unsafe extern "C" fn nova_rt_bytes_from_string(s: *const NovaStr) -> *mut NovaStr {
70    // SAFETY: forwarding this function's own contract.
71    gc_bytes(unsafe { as_bytes(s) })
72}
73
74/// Whether `b`'s bytes are valid UTF-8.
75///
76/// Paired with [`nova_rt_bytes_to_string_unchecked`] so `std/bytes`'s
77/// `to_string` can be a Nova-level `if`, rather than needing a status-code
78/// protocol for what is one boolean.
79///
80/// # Safety
81/// `b` must point to a live `NovaStr`.
82#[no_mangle]
83pub unsafe extern "C" fn nova_rt_bytes_is_utf8(b: *const NovaStr) -> i8 {
84    // SAFETY: forwarding this function's own contract.
85    i8::from(std::str::from_utf8(unsafe { as_bytes(b) }).is_ok())
86}
87
88/// `b`'s bytes as a `String`, **without** checking UTF-8 validity.
89///
90/// The caller must have established validity with
91/// [`nova_rt_bytes_is_utf8`] first; `std/bytes`'s `to_string` is the only
92/// caller and does exactly that. This copies `b`'s bytes into a fresh buffer
93/// via `gc_bytes` — an invalid `String` would be unsound, which is why the
94/// check is not optional.
95///
96/// # Safety
97/// `b` must point to a live `NovaStr` whose bytes are valid UTF-8.
98#[no_mangle]
99pub unsafe extern "C" fn nova_rt_bytes_to_string_unchecked(b: *const NovaStr) -> *mut NovaStr {
100    // SAFETY: forwarding this function's own contract. `gc_bytes` allocates a
101    // fresh object and copies `b`'s bytes into it, rather than reusing `b`'s
102    // own storage.
103    gc_bytes(unsafe { as_bytes(b) })
104}
105
106/// The byte at `i`, as an `Int` in `0..=255`.
107///
108/// Aborts on an out-of-range index. Nova's `Bytes::byte_at` bounds-checks first
109/// and returns `Option`, so this abort guards a bug in `std/bytes` rather than
110/// user error — and it aborts rather than panicking because a panic here could
111/// unwind through a generated poll frame.
112///
113/// # Safety
114/// `b` must point to a live `NovaStr`.
115#[no_mangle]
116pub unsafe extern "C" fn nova_rt_bytes_at(b: *const NovaStr, i: i64) -> i64 {
117    // SAFETY: forwarding this function's own contract.
118    let bytes = unsafe { as_bytes(b) };
119    let Ok(idx) = usize::try_from(i) else {
120        crate::task::abort_with("nova_rt_bytes_at: negative index");
121    };
122    match bytes.get(idx) {
123        Some(byte) => i64::from(*byte),
124        None => crate::task::abort_with("nova_rt_bytes_at: index out of range"),
125    }
126}
127
128/// The bytes in `start..end`, clamped to the buffer and to `start <= end`.
129///
130/// **Deliberately diverges from `String::slice`, which panics on an
131/// out-of-range bound (`std/strings/lib.nova`) rather than clamping.** A
132/// `Bytes` length often comes from disk (`fs::read`'s result, say), not from
133/// a caller who is expected to already know it the way a codepoint index
134/// is; aborting the whole process on a bound derived from external input is
135/// a worse failure mode than the same defect in a string index a caller
136/// chose by hand. The clamp below is two saturating `.max`/`.min` integer
137/// comparisons, which is what keeps this from ever aborting, unlike a bounds
138/// check that panics. This is a recorded, accepted divergence, not an
139/// oversight — see the design spec's dated note — and it does not imply
140/// `String::slice` will ever clamp too.
141///
142/// # Safety
143/// `b` must point to a live `NovaStr`.
144#[no_mangle]
145pub unsafe extern "C" fn nova_rt_bytes_slice(
146    b: *const NovaStr,
147    start: i64,
148    end: i64,
149) -> *mut NovaStr {
150    // SAFETY: forwarding this function's own contract.
151    let bytes = unsafe { as_bytes(b) };
152    let lo = start.max(0).min(bytes.len() as i64) as usize;
153    let hi = end.max(0).min(bytes.len() as i64) as usize;
154    if hi <= lo {
155        return gc_bytes(&[]);
156    }
157    gc_bytes(&bytes[lo..hi])
158}
159
160/// `a`'s bytes followed by `b`'s, in a freshly allocated buffer.
161///
162/// # Safety
163/// `a` and `b` must point to live `NovaStr`s.
164#[no_mangle]
165pub unsafe extern "C" fn nova_rt_bytes_concat(
166    a: *const NovaStr,
167    b: *const NovaStr,
168) -> *mut NovaStr {
169    // SAFETY: forwarding this function's own contract.
170    let (a, b) = unsafe { (as_bytes(a), as_bytes(b)) };
171    let mut out = Vec::with_capacity(a.len() + b.len());
172    out.extend_from_slice(a);
173    out.extend_from_slice(b);
174    gc_bytes(&out)
175}
176
177/// Decompose `b` into a Nova `[Int]`, one element per byte in `0..=255`.
178///
179/// The result must match **exactly** what codegen emits for an array: one
180/// block holding `{ len: i64, elem0, elem1, … }`, element `i` at byte offset
181/// `8 + 8*i`, allocated *scanned* — the same layout `nova_rt_str_chars`
182/// (`nova-runtime`'s crate root) builds for `[Char]`, and pinned here the same
183/// way that one is pinned, in this module's own `mod tests`
184/// (`to_ints_writes_the_array_layout_codegen_expects`, below).
185///
186/// # Safety
187/// `b` must point to a live `NovaStr`.
188#[no_mangle]
189pub unsafe extern "C" fn nova_rt_bytes_to_ints(b: *const NovaStr) -> *mut u8 {
190    // SAFETY: forwarding this function's own contract.
191    let bytes = unsafe { as_bytes(b) };
192    let n = bytes.len();
193    // `8` for the length header plus `8` per element, exactly as
194    // `nova_rt_str_chars` sizes its own `[Char]` block.
195    let block = crate::gc::alloc(8 + 8 * n, true);
196    let words = block as *mut i64;
197    // SAFETY: `block` is a fresh allocation of `8 + 8*n` bytes, so words
198    // `0..=n` are in bounds.
199    unsafe {
200        *words = n as i64;
201        for (i, byte) in bytes.iter().enumerate() {
202            *words.add(1 + i) = i64::from(*byte);
203        }
204    }
205    block
206}
207
208/// A `Bytes` holding each element of `ints` as one byte.
209///
210/// Aborts if any element is outside `0..=255`: that is a caller error (`std/
211/// bytes`'s `bytes_from_ints` is the only path to this intrinsic, and it
212/// documents the same contract), and a `Result` here would infect every
213/// construction path with a status protocol for what is a programmer mistake.
214///
215/// # Safety
216/// `ints` must point to a Nova array of `Int`: `{ len: i64, elems… }` with
217/// element `i` at byte offset `8 + 8*i` — the layout `nova_rt_str_from_chars`
218/// (`nova-runtime`'s crate root) reads for `[Char]`.
219#[no_mangle]
220pub unsafe extern "C" fn nova_rt_bytes_from_ints(ints: *const u8) -> *mut NovaStr {
221    let words = ints as *const i64;
222    // SAFETY: forwarding this function's own contract.
223    let n = unsafe { *words }.max(0) as usize;
224    let mut out = Vec::with_capacity(n);
225    for i in 0..n {
226        // SAFETY: `i < n`, and `n` is the array's own length, so this word is
227        // in bounds for the block `ints` describes.
228        let v = unsafe { *words.add(1 + i) };
229        let Ok(byte) = u8::try_from(v) else {
230            crate::task::abort_with("nova_rt_bytes_from_ints: element out of range 0..=255");
231        };
232        out.push(byte);
233    }
234    gc_bytes(&out)
235}
236
237/// Byte-for-byte equality of `a` and `b`.
238///
239/// # Safety
240/// `a` and `b` must point to live `NovaStr`s.
241#[no_mangle]
242pub unsafe extern "C" fn nova_rt_bytes_eq(a: *const NovaStr, b: *const NovaStr) -> i8 {
243    // SAFETY: forwarding this function's own contract.
244    i8::from(unsafe { as_bytes(a) == as_bytes(b) })
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    /// One leaf object per `Bytes` value: the header at its start, the payload
252    /// inline at offset 16
253    /// (`docs/superpowers/specs/2026-10-04-one-allocation-strings-design.md`).
254    unsafe fn assert_one_leaf_bytes(b: *mut NovaStr, expected: &[u8]) {
255        let len = expected.len();
256        assert_eq!(
257            crate::gc::object_info(b as usize),
258            Some((16 + len.max(1), false)),
259            "len {len}: one leaf object of 16 + max(len, 1) bytes"
260        );
261        assert_eq!(
262            (*b).ptr as usize,
263            b as usize + 16,
264            "len {len}: bytes inline at offset 16"
265        );
266        assert_eq!((*b).len, len as u64, "len {len}");
267        assert_eq!(as_bytes(b), expected, "len {len}");
268    }
269
270    const BYTES_LENGTHS: [usize; 6] = [0, 2, 113, 2032, 2033, 2048];
271
272    fn payload_of(len: usize) -> Vec<u8> {
273        (0..len).map(|i| (i % 251) as u8).collect()
274    }
275
276    #[test]
277    fn a_bytes_value_is_one_leaf_object_with_its_bytes_inline() {
278        for len in BYTES_LENGTHS {
279            let payload = payload_of(len);
280            // SAFETY: `gc_bytes` returns a live header.
281            unsafe { assert_one_leaf_bytes(gc_bytes(&payload), &payload) };
282        }
283    }
284
285    /// **Review finding I1, restated for one object.** A payload above 16
286    /// bytes makes the tracked size, `16 + len`, discriminate an allocation
287    /// that ignores `len`. Hardcoding the size to `17` must fail here; for any
288    /// payload over one byte that mutation is a heap-buffer overflow, since
289    /// the copy still writes `len` bytes.
290    #[test]
291    fn a_bytes_value_is_tracked_at_its_full_size() {
292        let b = gc_bytes(&[7u8; 32]);
293        assert_eq!(
294            crate::gc::object_info(b as usize),
295            Some((16 + 32, false)),
296            "a 32-byte payload is one object of 16 + 32 bytes"
297        );
298    }
299
300    #[test]
301    fn a_string_from_bytes_is_one_leaf_object_with_its_bytes_inline() {
302        for len in BYTES_LENGTHS {
303            let text: String = "abcdefghij".repeat(len / 10 + 1)[..len].to_string();
304            // SAFETY: the bytes are ASCII, so valid UTF-8.
305            unsafe {
306                let s = nova_rt_bytes_to_string_unchecked(gc_bytes(text.as_bytes()));
307                assert_one_leaf_bytes(s, text.as_bytes());
308            }
309        }
310    }
311
312    #[test]
313    fn a_bytes_slice_of_a_combined_object_is_its_own_leaf_object() {
314        let payload = payload_of(113);
315        // SAFETY: `gc_bytes` returns a live header.
316        unsafe {
317            let b = gc_bytes(&payload);
318            let s = nova_rt_bytes_slice(b, 10, 50);
319            assert_ne!(s, b, "a slice is a fresh object, never a view");
320            assert_one_leaf_bytes(s, &payload[10..50]);
321        }
322    }
323
324    /// **Decided before execution.** An earlier draft of this plan left
325    /// `bytes_is_utf8` with no test that could catch it until Task 3's Nova
326    /// fixture, and said so — a deliberate but avoidable gap, since `mod
327    /// tests` can reach the intrinsic directly with no Nova surface at all.
328    #[test]
329    fn is_utf8_distinguishes_valid_bytes_from_invalid() {
330        // Both directions, because a mutation to either constant is plausible and
331        // each is caught only by the case it gets wrong.
332        // SAFETY: both arguments are live headers from `gc_bytes`.
333        unsafe {
334            assert_eq!(nova_rt_bytes_is_utf8(gc_bytes(b"hi")), 1, "valid UTF-8");
335            assert_eq!(
336                nova_rt_bytes_is_utf8(gc_bytes(&[0xFF])),
337                0,
338                "0xFF is not a valid UTF-8 sequence on its own"
339            );
340        }
341    }
342
343    /// **Review finding M2.** Before this, the only exercise of
344    /// `nova_rt_bytes_len` was the `bytes_basics` fixture's single 2-byte
345    /// string — one data point, which cannot distinguish "returns the real
346    /// length" from a hardcoded constant. Proven, not assumed: hardcoding
347    /// the return to `2` passed the whole suite, fixture included (review
348    /// also checked the brief's other suggested probe, returning
349    /// `size_of::<NovaStr>()`, and that one *does* already die against the
350    /// existing fixture, since 16 != 2 — so the hardcoded-constant gap was
351    /// the only one left open). Two different real lengths rule out a
352    /// constant; the second is the 32-byte payload
353    /// `a_bytes_value_is_tracked_at_its_full_size` probes through
354    /// `object_info`, read here through the header's `len` field instead.
355    #[test]
356    fn bytes_len_reports_the_real_length_for_more_than_one_size() {
357        unsafe {
358            assert_eq!(nova_rt_bytes_len(gc_bytes(&[1, 2, 3])), 3);
359            assert_eq!(nova_rt_bytes_len(gc_bytes(&[7u8; 32])), 32);
360        }
361    }
362
363    /// Pins `nova_rt_bytes_to_ints`'s array layout directly, the same way
364    /// `nova-runtime`'s own `str_chars_writes_the_array_layout_codegen_expects`
365    /// pins `nova_rt_str_chars`'s. Two elements: `8 + 8*2 = 24` bytes, well
366    /// above `gc::alloc`'s 8-byte floor (`gc.rs`, `size.max(8)`), so the
367    /// tracked size can actually discriminate a correct allocation from a
368    /// wrong one -- a payload at or under the floor could not (see this
369    /// module's own `a_bytes_value_is_tracked_at_its_full_size` for the same
370    /// reasoning applied to a `Bytes` object).
371    #[test]
372    fn to_ints_writes_the_array_layout_codegen_expects() {
373        let b = gc_bytes(&[7, 8]);
374        // SAFETY: `b` is live; this is the same call Nova makes.
375        let block = unsafe { nova_rt_bytes_to_ints(b) };
376        assert_eq!(
377            crate::gc::object_info(block as usize),
378            Some((8 + 8 * 2, true)),
379            "an array block is a length word plus one word per element, SCANNED"
380        );
381        let words = block as *mut i64;
382        // SAFETY: the block is `8 + 8*2` bytes, so these three words are in bounds.
383        unsafe {
384            assert_eq!(*words, 2);
385            assert_eq!(*words.add(1), 7);
386            assert_eq!(*words.add(2), 8);
387        }
388    }
389
390    /// Pins this module's own panic-freedom contract (module doc comment
391    /// above: "nothing here may panic") mechanically, the way
392    /// `fs::tests::no_filesystem_intrinsic_registers_a_park` pins `std/fs`'s
393    /// "never parks" contract -- final review's recommendation (D11), added
394    /// after this exact module shipped a real native, non-unwinding panic
395    /// caught only by mutation testing, in code reachable from a generated
396    /// poll boundary with no landing pad to unwind through.
397    ///
398    /// **Follows `fs.rs`'s structure exactly, including why the split point
399    /// is the test module marker and not this function's own body.** The
400    /// needle array below names `unwrap()`, `.expect(`, `panic!`,
401    /// `format!` and `RefCell`, so splitting on this function alone
402    /// would leave those words in the scanned half and the test could never
403    /// pass. This test, like its sibling, must live inside this module and
404    /// not be hoisted out of it.
405    ///
406    /// **Splits on `mod tests {`, not the bare `#[cfg(test)]` attribute one
407    /// line above it (final review, routed item 1 -- the same reasoning
408    /// error as `fs.rs`'s I1, caught in a second place).** `#[cfg(test)]`
409    /// occurs three times in this file: the real attribute, plus this
410    /// paragraph and the needle scan below, both later in the file than it.
411    /// That was harmless only because both later occurrences happen to
412    /// follow the real one, not because the attribute text is unique --
413    /// splitting there was one stray production comment away from quoting
414    /// that exact attribute and silently scanning less than all of the real
415    /// production code, with no test failing to say so. `mod tests {` -- the
416    /// literal actually split on below -- is not unique either: it recurs in
417    /// this paragraph's own quotes of it and in the split call's own
418    /// argument. None of that matters to soundness, for the same reason it
419    /// does not matter for `#[cfg(test)]` above: only an occurrence
420    /// *before* the real declaration would silently truncate the scan, and
421    /// every one of these comes after it. See
422    /// `fs::tests::no_filesystem_intrinsic_registers_a_park`'s doc comment in
423    /// `fs.rs` for what actually keeps a split like this sound (the *first*
424    /// occurrence being the real boundary, not uniqueness), for the
425    /// guarantee this class of guard cannot make (**it fails open**: it
426    /// passes when it covers nothing), and for the ceiling on what a
427    /// substring scan can ever see.
428    ///
429    /// All five needles are currently absent from the production half, so
430    /// this passes today; it auto-covers every future intrinsic added above
431    /// without needing a recount, the same durability `fs.rs`'s version has
432    /// for `stage_park`.
433    #[test]
434    fn no_bytes_intrinsic_can_panic() {
435        let source = include_str!("bytes.rs");
436        let code = source.split("mod tests {").next().unwrap_or(source);
437        for needle in ["unwrap()", ".expect(", "panic!", "format!", "RefCell"] {
438            assert!(
439                !code.contains(needle),
440                "a std/bytes intrinsic must not panic: `{needle}` found in this \
441                 file's production code, which is reachable from a generated poll \
442                 boundary with no landing pad to unwind through"
443            );
444        }
445    }
446}