serpent-serializer 0.2.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 keyword_prefix_names_load_as_identifiers() {
    for src in ["double", "done", "doit", "normal", "math.huge"] {
        let v = load(src, &LoadOptions::default()).unwrap();
        let Value::Global(g) = v else {
            panic!("expected global for {src}")
        };
        assert_eq!(g.name, src);
    }

    for src in [
        "do local _={}; returnx = _; return _ end",
        "do local _={}; localx[1] = _; return _ end",
    ] {
        let v = load(src, &LoadOptions::default()).unwrap();
        assert!(matches!(v, Value::Table(_)));
    }
}

#[test]
fn bracketed_global_access_returns_syntax_errors() {
    let opts = LoadOptions { safe: Some(false) };

    for src in ["foo[", "foo[bar"] {
        let r = load(src, &opts);
        assert!(r.is_err(), "expected error for {src}, got {r:?}");
    }
}

#[test]
fn skipped_calls_ignore_parentheses_inside_strings() {
    let opts = LoadOptions { safe: Some(false) };
    let v = load("{a = math.random(')')}", &opts).unwrap();
    let Value::Table(t) = v else {
        panic!("expected table")
    };
    assert_eq!(t.get(&Key::Str(b"a".to_vec())), Some(Value::Nil));
}

#[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"));
}

#[test]
fn function_stub_rejects_missing_body_end() {
    let r = load("function()", &LoadOptions::default());
    assert!(r.is_err(), "expected error, got {r:?}");
}

#[test]
fn function_stub_rejects_open_if_block() {
    let r = load("function() if true then", &LoadOptions::default());
    assert!(r.is_err(), "expected error, got {r:?}");
}

#[test]
fn function_stub_rejects_if_end_without_body_end() {
    let r = load("function() if true then end", &LoadOptions::default());
    assert!(r.is_err(), "expected error, got {r:?}");
}

#[test]
fn function_stub_skips_opaque_body_tokens() {
    for src in [
        "function() local s = 'do' end",
        "function() local x = specif() end",
        "function() local name = doer end",
        "function() -- do\n end",
        "function() --[[do]] end",
        "function() local s = [[do]] end",
        "function() local s = [==[ do ]==] end",
        "function() --[==[ x ]==] end",
        "function() local s = \"\\\"do\\\"\" end",
    ] {
        let r = load(src, &LoadOptions::default());
        assert_eq!(r, Ok(Value::Nil), "expected nil for {src}");
    }
}