Skip to main content

samp_sdk/cell/
string.rs

1//! AMX strings: cell vector with `0` terminator.
2//!
3//! Pawn supports two binary representations:
4//!
5//! - **Unpacked**: 1 character per cell (4x memory usage, default).
6//! - **Packed**: 4 characters packed into each i32 cell (bits 31..24,
7//!   23..16, 15..8, 7..0). The first cell signals the mode if its value
8//!   exceeds [`MAX_UNPACKED`]; the SDK detects it automatically in [`to_bytes`].
9//!
10//! [`to_bytes`]: AmxString::to_bytes
11
12use std::cell::OnceCell;
13use std::fmt;
14use std::ops::Deref;
15
16use super::{AmxCell, Buffer, UnsizedBuffer};
17use crate::amx::Amx;
18#[cfg(feature = "encoding")]
19use crate::encoding;
20use crate::error::{AmxError, AmxResult};
21
22/// Upper bound for the first cell of an unpacked string.
23///
24/// Values above this indicate a packed string (4 chars/cell). The comparison
25/// is unsigned, as in the server (`(ucell)*cstr > UNPACKEDMAX`): a packed
26/// string whose first byte is 0x80 or above has a negative first cell.
27const MAX_UNPACKED: u32 = 0x00FF_FFFF;
28
29/// Longest string read from a script, in cells.
30const MAX_STRING_CELLS: usize = 1024 * 1024;
31
32fn is_packed(first: i32) -> bool {
33    first.cast_unsigned() > MAX_UNPACKED
34}
35
36/// Length of the string in `cells` (bytes when packed, cells when not), or
37/// `None` when no terminator comes before the end of `cells`.
38///
39/// Replaces the server's `amx_StrLen`, which has no bound: given an address
40/// near the top of the stack it walks past the end of the AMX memory.
41fn bounded_strlen(cells: &[i32]) -> Option<usize> {
42    if is_packed(*cells.first()?) {
43        let at = find_cell(cells, has_zero_byte)?;
44        let lead = cells[at].to_be_bytes().iter().position(|&byte| byte == 0)?;
45        Some(at * 4 + lead)
46    } else {
47        find_cell(cells, |cell| cell == 0)
48    }
49}
50
51/// Index of the first cell for which `ends` holds.
52///
53/// Whole blocks are tested without an early exit, which the compiler turns
54/// into vector code; only the block holding the match is searched cell by
55/// cell. A cell-by-cell search from the start cannot be vectorized and costs
56/// several times more on a long string.
57fn find_cell(cells: &[i32], ends: impl Fn(i32) -> bool + Copy) -> Option<usize> {
58    const BLOCK: usize = 16;
59    let block = cells
60        .chunks(BLOCK)
61        .position(|block| block.iter().fold(false, |found, &cell| found | ends(cell)))?;
62    let start = block * BLOCK;
63    Some(start + cells[start..].iter().position(|&cell| ends(cell))?)
64}
65
66/// Whether any of the four bytes of `cell` is zero, without looking at them
67/// one by one.
68fn has_zero_byte(cell: i32) -> bool {
69    let bits = cell.cast_unsigned();
70    bits.wrapping_sub(0x0101_0101) & !bits & 0x8080_8080 != 0
71}
72
73/// Native Pawn string — packed or unpacked.
74///
75/// Implements [`Deref<Target = str>`], so `&str` methods are available
76/// directly, without `.to_string()`:
77///
78/// ```no_run
79/// # use samp_sdk::cell::AmxString;
80/// # use samp_sdk::amx::Amx;
81/// # use samp_sdk::error::AmxResult;
82/// # struct Plugin;
83/// # impl Plugin {
84/// fn greet(&self, _amx: &Amx, name: AmxString) -> AmxResult<bool> {
85///     if name.starts_with("Admin") {
86///         println!("Welcome, {}!", &*name);
87///     }
88///     Ok(true)
89/// }
90/// # }
91/// ```
92///
93/// The decoded version (UTF-8 or Windows-1251 via the `encoding` feature) is
94/// computed on the first `Deref` call and cached — subsequent accesses
95/// return the `&str` without allocation.
96pub struct AmxString<'amx> {
97    inner: Buffer<'amx>,
98    len: usize,
99    decoded: OnceCell<String>,
100}
101
102impl<'amx> AmxString<'amx> {
103    /// Creates an `AmxString` from an allocated buffer and copies `bytes` (1 byte
104    /// per cell) with a trailing `0` terminator.
105    ///
106    /// # Safety
107    /// `buffer` must have at least `bytes.len() + 1` cells and remain
108    /// alive for `'amx`.
109    #[must_use]
110    pub unsafe fn new(mut buffer: Buffer<'amx>, bytes: &[u8]) -> AmxString<'amx> {
111        buffer.as_mut_slice()[..bytes.len()]
112            .iter_mut()
113            .zip(bytes)
114            .for_each(|(cell, &byte)| *cell = i32::from(byte));
115        buffer[bytes.len()] = 0;
116
117        AmxString {
118            len: bytes.len(),
119            inner: buffer,
120            decoded: OnceCell::new(),
121        }
122    }
123
124    /// Constructor for tests/benchmarks — assumes `inner` is already populated.
125    /// Not part of the stable API.
126    #[doc(hidden)]
127    #[must_use]
128    pub fn from_buffer_parts(inner: Buffer<'amx>, len: usize) -> AmxString<'amx> {
129        AmxString {
130            inner,
131            len,
132            decoded: OnceCell::new(),
133        }
134    }
135
136    /// Decodes the cells back into a `Vec<u8>`.
137    ///
138    /// Automatically detects packed (4 chars/cell) or unpacked (1 char/cell)
139    /// from the value of the first cell. Caps the read at 1 MiB to avoid
140    /// uncontrolled allocation if `len` is corrupted.
141    pub fn to_bytes(&self) -> Vec<u8> {
142        const MAX_STRING_LEN: usize = 1024 * 1024;
143        // An empty backing buffer has no first cell to probe for the
144        // packed/unpacked marker — return early instead of indexing `[0]`
145        // (which would panic). Reachable only via a corrupted length.
146        if self.inner.is_empty() {
147            return Vec::new();
148        }
149        let len = self.len.min(MAX_STRING_LEN);
150        let cells = self.inner.as_slice();
151
152        if is_packed(cells[0]) {
153            // Four bytes per cell, the first in the high byte. A cell with no
154            // zero byte goes in whole; the one holding the terminator, or
155            // reaching `len`, byte by byte.
156            let mut vec = Vec::with_capacity(len);
157            for &cell in cells {
158                let bytes = cell.to_be_bytes();
159                if !has_zero_byte(cell) && vec.len() + 4 <= len {
160                    vec.extend_from_slice(&bytes);
161                    continue;
162                }
163                let room = len - vec.len();
164                vec.extend(bytes.into_iter().take(room).take_while(|&byte| byte != 0));
165                break;
166            }
167            vec
168        } else {
169            // One byte per cell. A single pass the compiler vectorizes.
170            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
171            cells[..len.min(cells.len())]
172                .iter()
173                .map(|&cell| cell as u8)
174                .collect()
175        }
176    }
177
178    /// String length in characters (excluding the `0` terminator).
179    pub fn len(&self) -> usize {
180        self.len
181    }
182
183    /// `true` if the string is empty.
184    pub fn is_empty(&self) -> bool {
185        self.len == 0
186    }
187
188    /// Size of the underlying buffer in cells — always `>= len + 1`.
189    pub fn bytes_len(&self) -> usize {
190        self.inner.len()
191    }
192
193    /// Explicit form of the `Deref` to `&str`.
194    ///
195    /// Useful when type inference does not trigger auto-deref (e.g. a generic
196    /// context with `T: AsRef<str>`).
197    pub fn as_str(&self) -> &str {
198        self
199    }
200}
201
202/// Decodes the raw bytes using the configured encoding (UTF-8 by default;
203/// Windows-1251 etc. via the `encoding` feature).
204///
205/// Takes the bytes by value: when they are already the UTF-8 the result needs
206/// — ASCII, or valid UTF-8 — the `String` reuses their allocation instead of
207/// copying them.
208fn decode_bytes(bytes: Vec<u8>) -> String {
209    // Without BOM sniffing: the bytes are a Pawn string, often typed by a
210    // player, and one starting with FF FE must not switch to UTF-16.
211    #[cfg(feature = "encoding")]
212    if let std::borrow::Cow::Owned(text) = encoding::get().decode_without_bom_handling(&bytes).0 {
213        return text;
214    }
215    // Valid UTF-8 as it stands (always so when the encoding borrowed it).
216    String::from_utf8(bytes)
217        .unwrap_or_else(|invalid| String::from_utf8_lossy(invalid.as_bytes()).into_owned())
218}
219
220impl<'amx> AmxCell<'amx> for AmxString<'amx> {
221    fn from_raw(amx: &'amx Amx, cell: i32) -> AmxResult<AmxString<'amx>> {
222        let buffer = UnsizedBuffer::from_raw(amx, cell)?;
223        let str_len = match buffer.max_cells() {
224            // Null VM (no `stp` to bound by): only the server can tell.
225            usize::MAX => amx.strlen(buffer.as_ptr())?,
226            max_cells => {
227                // SAFETY: `[cell, stp)` is AMX memory, alive for `'amx`.
228                let cells = unsafe {
229                    std::slice::from_raw_parts(buffer.as_ptr(), max_cells.min(MAX_STRING_CELLS))
230                };
231                bounded_strlen(cells).ok_or(AmxError::MemoryAccess)?
232            }
233        };
234        let buf_len = str_len + 1;
235
236        Ok(AmxString {
237            inner: buffer.into_sized_buffer(buf_len),
238            len: str_len,
239            decoded: OnceCell::new(),
240        })
241    }
242
243    fn as_cell(&self) -> i32 {
244        self.inner.as_cell()
245    }
246}
247
248impl Deref for AmxString<'_> {
249    type Target = str;
250
251    /// Decodes on the first call and caches in [`OnceCell`] — subsequent
252    /// accesses return the same `&str` without allocation.
253    fn deref(&self) -> &str {
254        self.decoded.get_or_init(|| decode_bytes(self.to_bytes()))
255    }
256}
257
258impl fmt::Display for AmxString<'_> {
259    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
260        fmt.write_str(self)
261    }
262}
263
264impl PartialEq<str> for AmxString<'_> {
265    /// Direct comparison with `&str` (`name == "Admin"`) — no extra allocation.
266    fn eq(&self, other: &str) -> bool {
267        &**self == other
268    }
269}
270
271impl PartialEq<&str> for AmxString<'_> {
272    fn eq(&self, other: &&str) -> bool {
273        &**self == *other
274    }
275}
276
277impl PartialEq<String> for AmxString<'_> {
278    fn eq(&self, other: &String) -> bool {
279        &**self == other.as_str()
280    }
281}
282
283/// Copies a Rust string into an AMX `Buffer` (1 byte per cell, `0`
284/// terminator at the end).
285///
286/// Internal implementation shared by [`Buffer::write_str`] and
287/// [`UnsizedBuffer::write_str`] — the public API goes through them.
288///
289/// [`Buffer::write_str`]: crate::cell::buffer::Buffer::write_str
290/// [`UnsizedBuffer::write_str`]: crate::cell::buffer::UnsizedBuffer::write_str
291///
292/// # Errors
293/// `AmxError::General` if `string` (after encoding) is >= the buffer size.
294pub(crate) fn put_in_buffer(buffer: &mut Buffer, string: &str) -> AmxResult<()> {
295    put_in_buffer_checked(buffer, string).map(|_| ())
296}
297
298/// Same as [`put_in_buffer`], reporting whether the encoding had to substitute
299/// characters it could not represent.
300pub(crate) fn put_in_buffer_checked(buffer: &mut Buffer, string: &str) -> AmxResult<bool> {
301    #[cfg(feature = "encoding")]
302    let (bytes, had_unmappable) = encoding::encode_checked(string);
303
304    // Without the feature the bytes are the string's own UTF-8: nothing to map,
305    // so nothing can be lost.
306    #[cfg(not(feature = "encoding"))]
307    let (bytes, had_unmappable) = (std::borrow::Cow::from(string.as_bytes()), false);
308
309    let bytes = bytes.as_ref();
310
311    if bytes.len() >= buffer.len() {
312        return Err(crate::error::AmxError::General);
313    }
314
315    buffer.as_mut_slice()[..bytes.len()]
316        .iter_mut()
317        .zip(bytes)
318        .for_each(|(cell, &byte)| *cell = i32::from(byte));
319
320    buffer[bytes.len()] = 0;
321
322    Ok(had_unmappable)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::cell::Ref;
329
330    fn make_buffer(data: &mut Vec<i32>) -> Buffer<'_> {
331        let len = data.len();
332        let r = unsafe { Ref::new(0, data.as_mut_ptr()) };
333        Buffer::new(r, len)
334    }
335
336    // --- Length without the server's `amx_StrLen` ---
337
338    #[test]
339    fn bounded_strlen_stops_at_the_terminator() {
340        assert_eq!(bounded_strlen(&[0x41, 0x42, 0, 0x43]), Some(2));
341        assert_eq!(bounded_strlen(&[0x4142_4300]), Some(3));
342        assert_eq!(bounded_strlen(&[0x4142_4344, 0x4500_0000]), Some(5));
343        assert_eq!(bounded_strlen(&[0]), Some(0));
344        // Past the first block of cells, and a terminator on a block edge.
345        let mut long = vec![0x41; 40];
346        long[37] = 0;
347        assert_eq!(bounded_strlen(&long), Some(37));
348        long[16] = 0;
349        assert_eq!(bounded_strlen(&long), Some(16));
350        let mut packed = vec![0x4142_4344; 40];
351        packed[20] = 0x4142_0044;
352        assert_eq!(bounded_strlen(&packed), Some(20 * 4 + 2));
353    }
354
355    #[test]
356    fn bounded_strlen_agrees_with_a_byte_by_byte_search() {
357        fn naive(cells: &[i32]) -> Option<usize> {
358            let first = *cells.first()?;
359            if first.cast_unsigned() > 0x00FF_FFFF {
360                let bytes = cells.iter().flat_map(|cell| cell.to_be_bytes());
361                bytes
362                    .enumerate()
363                    .find(|&(_, byte)| byte == 0)
364                    .map(|(at, _)| at)
365            } else {
366                cells.iter().position(|&cell| cell == 0)
367            }
368        }
369        // A small linear congruential generator: deterministic, no dependency.
370        let mut state = 0x2545_f491_u32;
371        let mut next = move || {
372            state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
373            state
374        };
375        for _ in 0..5000 {
376            let len = (next() % 70) as usize;
377            let packed = next() % 2 == 0;
378            let mut cells: Vec<i32> = (0..len)
379                .map(|_| {
380                    let value = next() | 0x0101_0101;
381                    if packed {
382                        value.cast_signed()
383                    } else {
384                        (value & 0xFF).cast_signed()
385                    }
386                })
387                .collect();
388            if len > 0 && next() % 4 != 0 {
389                let at = (next() as usize) % len;
390                if packed {
391                    let byte = (next() % 4) as usize;
392                    let mut bytes = cells[at].to_be_bytes();
393                    bytes[byte] = 0;
394                    cells[at] = i32::from_be_bytes(bytes);
395                } else {
396                    cells[at] = 0;
397                }
398            }
399            assert_eq!(bounded_strlen(&cells), naive(&cells), "{cells:x?}");
400        }
401    }
402
403    #[test]
404    fn zero_bytes_are_found_in_any_position() {
405        for cell in [
406            0x0041_4243,
407            0x4100_4243,
408            0x4142_0043,
409            0x4142_4300,
410            0x0000_0000,
411        ] {
412            assert!(has_zero_byte(cell), "{cell:#x}");
413        }
414        for cell in [0x4142_4344, 0x0101_0101, 0x8080_8080_u32.cast_signed(), -1] {
415            assert!(!has_zero_byte(cell), "{cell:#x}");
416        }
417    }
418
419    #[test]
420    fn bounded_strlen_without_terminator_is_none() {
421        // A string running to the end of the AMX memory: the server's
422        // `amx_StrLen` would keep reading past it.
423        assert_eq!(bounded_strlen(&[0x41, 0x42]), None);
424        assert_eq!(bounded_strlen(&[0x4142_4344]), None);
425        assert_eq!(bounded_strlen(&[]), None);
426    }
427
428    #[cfg(feature = "encoding")]
429    #[test]
430    fn a_leading_bom_does_not_change_the_encoding() {
431        let _g = crate::encoding::tests_lock();
432        // "\u{ff}\u{fe}ab" in Windows-1252 (the default), which also starts
433        // like a UTF-16LE BOM.
434        let mut data = vec![0xFF, 0xFE, 0x61, 0x62, 0];
435        let s = AmxString::from_buffer_parts(make_buffer(&mut data), 4);
436        assert_eq!(&*s, "\u{ff}\u{fe}ab");
437    }
438
439    #[test]
440    fn packed_with_a_high_first_byte_is_packed() {
441        // `!"\233xyz"`: 0xE9 makes the first cell negative. The server
442        // compares unsigned, so this is packed, not one cell per byte.
443        let mut data = vec![0xE978_797Au32.cast_signed(), 0];
444        assert_eq!(bounded_strlen(&data), Some(4));
445        let s = AmxString::from_buffer_parts(make_buffer(&mut data), 4);
446        assert_eq!(s.to_bytes(), [0xE9, b'x', b'y', b'z']);
447    }
448
449    // --- Unpacked strings (one byte per cell) ---
450
451    #[test]
452    fn new_empty_string() {
453        let mut data = vec![0i32; 4];
454        let buf = make_buffer(&mut data);
455        let s = unsafe { AmxString::new(buf, b"") };
456        assert!(s.is_empty());
457        assert_eq!(s.len(), 0);
458        assert_eq!(&*s, "");
459        assert_eq!(s.to_bytes(), b"");
460    }
461
462    #[test]
463    fn new_ascii_string() {
464        let mut data = vec![0i32; 16];
465        let buf = make_buffer(&mut data);
466        let s = unsafe { AmxString::new(buf, b"hello") };
467        assert_eq!(s.len(), 5);
468        assert_eq!(&*s, "hello");
469        assert_eq!(s.to_bytes(), b"hello");
470        assert!(!s.is_empty());
471    }
472
473    #[test]
474    fn deref_str_enables_string_methods() {
475        let mut data = vec![0i32; 32];
476        let buf = make_buffer(&mut data);
477        let s = unsafe { AmxString::new(buf, b"hello world") };
478        // &str methods without .to_string()
479        assert!(s.contains("world"));
480        assert!(s.starts_with("hello"));
481        assert!(s.ends_with("world"));
482        assert_eq!(s.to_uppercase(), "HELLO WORLD");
483        assert_eq!(s.split_once(' ').unwrap(), ("hello", "world"));
484    }
485
486    #[test]
487    fn deref_is_lazy_and_cached() {
488        let mut data = vec![0i32; 16];
489        let buf = make_buffer(&mut data);
490        let s = unsafe { AmxString::new(buf, b"world") };
491        // OnceCell has not been initialized yet
492        assert!(s.decoded.get().is_none());
493        // First access via Deref -> initializes
494        let _ = &*s;
495        assert!(s.decoded.get().is_some());
496        // Second access -> same pointer (cache hit)
497        let a = s.decoded.get().unwrap().as_ptr();
498        let _ = &*s;
499        let b = s.decoded.get().unwrap().as_ptr();
500        assert_eq!(a, b);
501    }
502
503    #[test]
504    fn display_and_deref_are_consistent() {
505        let mut data = vec![0i32; 16];
506        let buf = make_buffer(&mut data);
507        let s = unsafe { AmxString::new(buf, b"world") };
508        assert_eq!(s.to_string(), "world");
509        assert_eq!(&*s, "world");
510        assert_eq!(format!("{s}"), "world");
511    }
512
513    #[test]
514    fn bytes_len_reflects_buffer_size() {
515        let mut data = vec![0i32; 8];
516        let buf = make_buffer(&mut data);
517        let s = unsafe { AmxString::new(buf, b"abc") };
518        assert_eq!(s.bytes_len(), 8);
519        assert_eq!(s.len(), 3);
520    }
521
522    #[test]
523    fn unpacked_to_bytes_ascii() {
524        let text = b"SA-MP Plugin";
525        let mut data: Vec<i32> = text
526            .iter()
527            .map(|&b| i32::from(b))
528            .chain(std::iter::once(0))
529            .collect();
530        let buf = make_buffer(&mut data);
531        let s = unsafe { AmxString::new(buf, text) };
532        assert_eq!(s.to_bytes(), text);
533    }
534
535    #[test]
536    fn unpacked_single_char() {
537        let mut data = vec![0x41i32, 0];
538        let buf = make_buffer(&mut data);
539        let s = unsafe { AmxString::new(buf, b"A") };
540        assert_eq!(s.len(), 1);
541        assert_eq!(&*s, "A");
542    }
543
544    // --- Packed strings (4 bytes per cell) ---
545    //
546    // Bytes read from each cell: bits[31..24], [23..16], [15..8], [7..0].
547    // "ABCD" -> cell = 0x41424344, next cell = 0x00000000 (null)
548
549    #[test]
550    fn packed_four_chars_one_cell() {
551        let mut data = vec![0x4142_4344i32, 0x0000_0000i32];
552        let buf = make_buffer(&mut data);
553        let s = AmxString::from_buffer_parts(buf, 4);
554        assert_eq!(s.to_bytes(), b"ABCD");
555        assert_eq!(&*s, "ABCD");
556    }
557
558    #[test]
559    fn packed_five_chars_two_cells() {
560        // "ABCDE": 4 chars in cell[0], 1 in cell[1]
561        let mut data = vec![0x4142_4344i32, 0x4500_0000i32, 0x0000_0000i32];
562        let buf = make_buffer(&mut data);
563        let s = AmxString::from_buffer_parts(buf, 5);
564        assert_eq!(s.to_bytes(), b"ABCDE");
565        assert_eq!(&*s, "ABCDE");
566    }
567
568    #[test]
569    fn packed_truncates_at_len() {
570        let mut data = vec![0x4142_4344i32, 0x0000_0000i32];
571        let buf = make_buffer(&mut data);
572        let s = AmxString::from_buffer_parts(buf, 2);
573        assert_eq!(s.to_bytes(), b"AB");
574    }
575
576    #[test]
577    fn packed_stops_at_null_byte() {
578        // "AB\0D" -> stops at \0, returns "AB"
579        let mut data = vec![0x4142_0044i32, 0x0000_0000i32];
580        let buf = make_buffer(&mut data);
581        let s = AmxString::from_buffer_parts(buf, 4);
582        assert_eq!(s.to_bytes(), b"AB");
583    }
584
585    // --- as_str ---
586
587    #[test]
588    fn as_str_returns_decoded() {
589        let mut data = vec![0i32; 16];
590        let buf = make_buffer(&mut data);
591        let s = unsafe { AmxString::new(buf, b"hello") };
592        assert_eq!(s.as_str(), "hello");
593    }
594
595    #[test]
596    fn as_str_and_deref_are_same_pointer() {
597        let mut data = vec![0i32; 16];
598        let buf = make_buffer(&mut data);
599        let s = unsafe { AmxString::new(buf, b"rust") };
600        // Both trigger the same OnceCell — same &str pointer
601        let a: &str = s.as_str();
602        let b: &str = &s;
603        assert_eq!(a.as_ptr(), b.as_ptr());
604    }
605
606    // --- PartialEq ---
607
608    #[test]
609    fn partial_eq_str_literal() {
610        let mut data = vec![0i32; 16];
611        let buf = make_buffer(&mut data);
612        let s = unsafe { AmxString::new(buf, b"Admin") };
613        assert!(s == "Admin");
614        assert!(s != "admin");
615    }
616
617    #[test]
618    fn partial_eq_ref_str() {
619        let mut data = vec![0i32; 16];
620        let buf = make_buffer(&mut data);
621        let s = unsafe { AmxString::new(buf, b"samp") };
622        let key: &str = "samp";
623        assert!(s == key);
624    }
625
626    #[test]
627    fn partial_eq_string() {
628        let mut data = vec![0i32; 16];
629        let buf = make_buffer(&mut data);
630        let s = unsafe { AmxString::new(buf, b"plugin") };
631        let owned_match: String = "plugin".to_string();
632        let owned_other: String = "other".to_string();
633        assert!(s == owned_match);
634        assert!(s != owned_other);
635    }
636
637    #[test]
638    fn partial_eq_empty() {
639        let mut data = vec![0i32; 4];
640        let buf = make_buffer(&mut data);
641        let s = unsafe { AmxString::new(buf, b"") };
642        assert!(s.is_empty());
643        assert!(s != "x");
644    }
645
646    // --- put_in_buffer ---
647
648    #[test]
649    fn put_in_buffer_writes_correctly() {
650        let mut data = vec![0i32; 16];
651        let mut buf = make_buffer(&mut data);
652        put_in_buffer(&mut buf, "hello").unwrap();
653        assert_eq!(buf[0], i32::from(b'h'));
654        assert_eq!(buf[4], i32::from(b'o'));
655        assert_eq!(buf[5], 0);
656    }
657
658    #[test]
659    fn put_in_buffer_exact_fit_fails() {
660        let mut data = vec![0i32; 5];
661        let mut buf = make_buffer(&mut data);
662        assert!(put_in_buffer(&mut buf, "hello").is_err());
663    }
664
665    #[test]
666    fn put_in_buffer_empty_string() {
667        let mut data = vec![0i32; 4];
668        let mut buf = make_buffer(&mut data);
669        put_in_buffer(&mut buf, "").unwrap();
670        assert_eq!(buf[0], 0);
671    }
672
673    // --- Adversarial / property tests: decoding must never panic or overrun,
674    //     whatever garbage (or a corrupted length) the script hands over. ---
675
676    /// Tiny deterministic LCG — dependency-free pseudo-randomness for fuzzing.
677    fn lcg(seed: &mut u64) -> u32 {
678        *seed = seed
679            .wrapping_mul(6_364_136_223_846_793_005)
680            .wrapping_add(1_442_695_040_888_963_407);
681        (*seed >> 33) as u32
682    }
683
684    #[test]
685    fn to_bytes_declared_len_larger_than_buffer_is_bounded() {
686        // A corrupted length far beyond the backing cells must read only what
687        // exists, never past the slice.
688        let mut data = vec![0x41i32, 0x42, 0x43]; // "ABC", no terminator
689        let buf = make_buffer(&mut data);
690        let s = AmxString::from_buffer_parts(buf, 9999);
691        let bytes = s.to_bytes();
692        assert!(bytes.len() <= 3, "read past the backing buffer: {bytes:?}");
693    }
694
695    #[test]
696    fn to_bytes_non_utf8_decodes_lossy_without_panic() {
697        // 0xFF is not valid UTF-8; decoding must produce replacement chars,
698        // never panic.
699        let mut data = vec![0xFFi32, 0xFE, 0x41, 0];
700        let buf = make_buffer(&mut data);
701        let s = AmxString::from_buffer_parts(buf, 3);
702        let _ = &*s; // triggers decode
703        assert!(!s.is_empty());
704    }
705
706    #[test]
707    fn fuzz_decode_never_panics() {
708        // Random cell contents + a possibly-corrupted declared length, for both
709        // packed and unpacked interpretations. The contract: decoding is total
710        // (no panic, no overrun) no matter what the VM memory holds.
711        let mut seed = 0x0BAD_F00D_DEAD_BEEFu64;
712        for _ in 0..4000 {
713            let cells = (lcg(&mut seed) % 12) as usize + 1; // 1..=12 cells
714            let mut data: Vec<i32> = (0..cells).map(|_| lcg(&mut seed) as i32).collect();
715            // Declared length may be anything, including far beyond `cells`.
716            let declared = (lcg(&mut seed) % 64) as usize;
717            let buf = make_buffer(&mut data);
718            let s = AmxString::from_buffer_parts(buf, declared);
719
720            let bytes = s.to_bytes();
721            assert!(bytes.len() <= 1024 * 1024);
722            // Deref decodes and caches — must also be total.
723            let decoded = &*s;
724            assert!(decoded.len() <= bytes.len().max(4 * bytes.len() + 4));
725        }
726    }
727}