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}