serpent-serializer 0.1.0

Serialize Lua values to round-trippable Lua source with cycle and shared-reference handling
Documentation
//! Deserializer behavior.
//!
//! Covers loading `line`, `block`, and `dump` output, the `return` prefix the
//! expression forms need, and the sandbox that rejects function calls in safe
//! mode.

mod common;

use common::deep_eq;
use serpent_serializer::load::LoadError;
use serpent_serializer::value::{Key, Table, Value};
use serpent_serializer::{block, dump, line, load, LoadOptions};

#[test]
fn load_number() {
    let src = line(&Value::Number(10.0)).unwrap();
    let v = load(&src, &LoadOptions::default()).unwrap();
    assert!(matches!(v, Value::Number(n) if n == 10.0));
}

#[test]
fn load_boolean() {
    let src = line(&Value::Bool(true)).unwrap();
    let v = load(&src, &LoadOptions::default()).unwrap();
    assert!(matches!(v, Value::Bool(true)));
}

#[test]
fn load_pretty_table() {
    // line({3,4})
    let t = Table::new();
    t.push(Value::Number(3.0));
    t.push(Value::Number(4.0));
    let src = line(&Value::Table(t)).unwrap();
    let v = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(back) = v else {
        panic!("not a table")
    };
    let e = back.entries();
    assert_eq!(e.len(), 2);
    assert!(matches!(e[0].1, Value::Number(n) if n == 3.0));
    assert!(matches!(e[1].1, Value::Number(n) if n == 4.0));
}

#[test]
fn load_dumped_table() {
    // dump({3,4})
    let t = Table::new();
    t.push(Value::Number(3.0));
    t.push(Value::Number(4.0));
    let src = dump(&Value::Table(t)).unwrap();
    let v = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(back) = v else {
        panic!("not a table")
    };
    assert_eq!(back.border(), 2);
}

#[test]
fn safe_rejects_calls() {
    let r = load("{a = math.random()}", &LoadOptions::default());
    assert_eq!(r.unwrap_err(), LoadError::UnsafeCall);
}

#[test]
fn unsafe_allows_calls() {
    let opts = LoadOptions { safe: Some(false) };
    let r = load("{a = math.random()}", &opts);
    assert!(r.is_ok());
}

#[test]
fn safe_rejects_do_error() {
    let r = load("do error('not allowed') end", &LoadOptions::default());
    assert_eq!(r.unwrap_err(), LoadError::UnsafeCall);
}

#[test]
fn rejects_trailing_call_after_expression() {
    // A parsed value followed by more source must not succeed. The call would
    // otherwise slip past the sandbox because parsing stopped early.
    let r = load("{a=1}; os.execute(\"x\")", &LoadOptions::default());
    assert!(r.is_err(), "expected error, got {r:?}");
}

#[test]
fn rejects_do_block_without_end() {
    // An incomplete chunk must fail, not return the partial value.
    let r = load("do local _={}; return _", &LoadOptions::default());
    assert!(r.is_err(), "expected error, got {r:?}");
}

#[test]
fn sandbox_allows_global_assignment() {
    // These all run without error and cannot touch real globals, because the
    // targets resolve against the sandbox.
    for src in [
        "do print = error end",
        "do _G.print = error end",
        "do _G._G.print = error end",
        "do _G = nil _G.print = error end",
    ] {
        let r = load(src, &LoadOptions::default());
        assert!(r.is_ok(), "expected ok for {src}, got {r:?}");
    }
}

#[test]
fn block_round_trips() {
    // Mixed table survives block -> load.
    let t = Table::new();
    t.set(Key::Str(b"a".to_vec()), Value::Str(b"a".to_vec()));
    t.set(Key::Str(b"b".to_vec()), Value::Str(b"b".to_vec()));
    t.push(Value::Number(1.0));
    t.push(Value::Number(2.0));
    let src = block(&Value::Table(t.clone())).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    assert!(deep_eq(&Value::Table(t), &back));
}

#[test]
fn leading_comment_in_table_parses() {
    // A comment before the first element must be skipped, not treated as data.
    let v = load("{--[[x]] 1, 2}", &LoadOptions::default()).unwrap();
    let Value::Table(t) = v else { panic!() };
    let e = t.entries();
    assert_eq!(e.len(), 2);
    assert!(matches!(e[0].1, Value::Number(n) if n == 1.0));
    assert!(matches!(e[1].1, Value::Number(n) if n == 2.0));
}

#[test]
fn empty_table_with_only_comment_parses() {
    let v = load("{--[[x]]}", &LoadOptions::default()).unwrap();
    let Value::Table(t) = v else { panic!() };
    assert!(t.is_empty());
}

#[test]
fn string_escaping_round_trips() {
    // The escaping fixture: quote, newline, apostrophe, backslash, NUL.
    let t = Table::new();
    t.set(Key::Str(b"str".to_vec()), Value::Str(b"\"\n'\\\0".to_vec()));
    let src = block(&Value::Table(t.clone())).unwrap();
    let back = load(&src, &LoadOptions::default()).unwrap();
    let Value::Table(bt) = back else {
        panic!("not table")
    };
    let got = bt.get(&Key::Str(b"str".to_vec())).unwrap();
    assert!(matches!(got, Value::Str(ref s) if s == b"\"\n'\\\0"));
}