├── .busted ├── .github └── workflows │ └── run_tests.yaml ├── .gitignore ├── LICENSE ├── README.md ├── benchmark ├── run.lua └── utils.lua ├── docs ├── api.md ├── development.md ├── overloading.md ├── safety.md └── serializing_modules.md ├── init.lua └── tests ├── _busted.lua ├── resources └── deterministic.lua ├── test_ldump.lua ├── test_safety.lua ├── test_static.lua ├── test_use_case.lua └── utils.lua /.busted: -------------------------------------------------------------------------------- 1 | return { 2 | default = { 3 | ROOT = {"."}, 4 | pattern = "test_", 5 | verbose = true, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/run_tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: [push] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | luaVersion: ["5.4", "5.3", "5.2", "5.1", "luajit-openresty"] 9 | 10 | # would not test on windows, the leafo-gh-actions-lua bugs out even with presetup 11 | runs-on: ubuntu-22.04 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Setup Lua 18 | uses: leafo/gh-actions-lua@v10 19 | with: 20 | luaVersion: ${{ matrix.luaVersion }} 21 | buildCache: false 22 | 23 | - name: Setup Luarocks 24 | uses: leafo/gh-actions-luarocks@v4 25 | 26 | - name: Setup busted 27 | run: luarocks install busted 28 | 29 | - name: Run Busted 30 | run: busted -v 31 | 32 | - name: Run Busted in safety mode 33 | run: LDUMP_TEST_SAFETY=1 busted -v 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | luacov.stats.out 2 | luacov.report.out 3 | test.lua 4 | _deps 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2025 girvel a.k.a. Nikita Dobrynin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [API](/docs/api.md) | [Overloading serialization](/docs/overloading.md) | [Serializing modules](/docs/serializing_modules.md) | [Safety](/docs/safety.md) | [Development](/docs/development.md) 2 | 3 | # ldump — serialization library for any lua type 4 | 5 | `ldump` is a flexible serializer, able to serialize any data, starting with circular references, tables as keys, functions with upvalues, metatables and ending with coroutines, threads and userdata (by defining how they should be serialized). It outputs valid Lua code that recreates the original object, doing the deserialization through `load(data)()`. It aims for functionality and flexibility instead of speed and size, allowing full serialization of complex data, such as video game saves. The output is large, but can be drastically reduced with modern compression algorithms. 6 | 7 | Inspired by [`Ser`](https://github.com/gvx/Ser). Supports Lua 5.1, 5.2, 5.3, 5.4 and LuaJIT. Tested for edge cases, such as joined upvalues and _ENV redefinition. Fully annotated in compatibility with LuaLS. 8 | 9 | **WARNING:** `ldump`'s deserialization function is Lua's builtin `load`, which can load malicious code. Consider using JSON for untrusted data or use [safety measures](/docs/safety.md). 10 | 11 | | Type | Support | 12 | | ----------------------------------------- | ------------ | 13 | | nil, boolean, number, string | full | 14 | | function | full | 15 | | userdata | user-defined | 16 | | thread | user-defined | 17 | | table | full | 18 | | metatables[*](/docs/development.md#plans) | full | 19 | 20 | 21 | ## TL;DR show me the code 22 | 23 | ```lua 24 | local ldump = require("ldump") 25 | 26 | local upvalue = 42 27 | local world = { 28 | name = "New world", 29 | get_answer = function() return upvalue end, 30 | } 31 | 32 | local serialized_data = ldump(world) -- serialize to a string 33 | local loaded_world = load(serialized_data)() -- deserialize the string 34 | 35 | assert.are_equal(world.name, loaded_world.name) 36 | assert.are_equal(world.get_answer(), loaded_world.get_answer()) 37 | ``` 38 | 39 | See as a test at [/tests/test_use_case.lua:7](/tests/test_use_case.lua#L7) 40 | 41 | 42 | ## The full power of ldump 43 | 44 | ```lua 45 | local ldump = require("ldump") 46 | 47 | -- basic tables 48 | local game_state = { 49 | player = {name = "Player"}, 50 | boss = {name = "Boss"}, 51 | } 52 | 53 | -- circular references & tables as keys 54 | game_state.deleted_entities = { 55 | [game_state.boss] = true, 56 | } 57 | 58 | -- functions even with upvalues 59 | local upvalue = 42 60 | game_state.get_answer = function() return upvalue end 61 | 62 | -- fundamentally non-serializable types if overridden 63 | local create_coroutine = function() 64 | return coroutine.wrap(function() 65 | coroutine.yield(1337) 66 | coroutine.yield(420) 67 | end) 68 | end 69 | 70 | -- override serialization 71 | game_state.coroutine = create_coroutine() 72 | ldump.serializer.handlers[game_state.coroutine] = create_coroutine 73 | 74 | local serialized_data = ldump(game_state) -- serialize 75 | local loaded_game_state = load(serialized_data)() -- deserialize 76 | 77 | -- assert 78 | assert.are_equal(game_state.get_answer(), loaded_game_state.get_answer()) 79 | assert.are_equal(game_state.coroutine(), loaded_game_state.coroutine()) 80 | assert.are_equal(game_state.coroutine(), loaded_game_state.coroutine()) 81 | 82 | assert.are_same(game_state.player, loaded_game_state.player) 83 | assert.are_same(game_state.boss, loaded_game_state.boss) 84 | assert.are_same( 85 | game_state.deleted_entities[game_state.boss], 86 | loaded_game_state.deleted_entities[loaded_game_state.boss] 87 | ) 88 | ``` 89 | 90 | See as a test at [/tests/test_use_case.lua:23](/tests/test_use_case.lua#L23) 91 | 92 | 93 | ## Installation 94 | 95 | - *Traditional way:* copy the [raw contents of init.lua from the latest release](https://raw.githubusercontent.com/girvel/ldump/refs/tags/v1.4.0/init.lua) into your `/ldump.lua` 96 | - *Recommended way:* `git clone -b v1.4.0 https://github.com/girvel/ldump` inside the `/` — you still would be able to do `require("ldump")`, and it would allow version management through git 97 | 98 | --- 99 | 100 | ## Credits 101 | 102 | - [paulstelian97](https://www.reddit.com/user/paulstelian97/) for providing a joined upvalue test case 103 | - [lambda_abstraction](https://www.reddit.com/user/lambda_abstraction/) for suggesting a way to join upvalues 104 | - [jhatemyjob](https://news.ycombinator.com/user?id=jhatemyjob) for the special characters test case 105 | - [lifthrasiir](https://news.ycombinator.com/user?id=lifthrasiir) for pointing out safety issues 106 | - [radioflash](https://github.com/radioflash) for suggesting a simpler module serialization logic and suggestions for publicity 107 | -------------------------------------------------------------------------------- /benchmark/run.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";./benchmark/_deps/?.lua" 2 | local ldump = require("init") 3 | local binser = require("binser") 4 | local bitser = require("bitser") 5 | local inspect = require("inspect") 6 | local utils = require("benchmark.utils") 7 | math.randomseed(os.time()) 8 | 9 | 10 | --[[ 11 | output should look like this: 12 | 13 | iterations: 10 14 | 15 | serializer | serialization time | deserialization time | memory 16 | ------------------------ | ------------------ | -------------------- | ------ 17 | binser | 1.32 s | 2.22 s | 1.3 KB 18 | bitser | 1.31 s | 2.01 s | 1.3 KB 19 | ldump raw | 1.5 s | 3 s | 200 KB 20 | ldump with compression_1 | 1.6 s | 3.3 s | 2 KB 21 | ldump with compression_2 | 1.7 s | 3.8 s | 2.5 KB 22 | 23 | maybe binser/bitser should be attempted with compression too 24 | ]] 25 | 26 | 27 | -- data -- 28 | -- should include large array with gaps, containing entities with repeating fields 29 | local data = { 30 | entities = {}, 31 | } 32 | 33 | local field_names = {} 34 | 35 | local a_pos = string.byte("a") 36 | local random_letter = function() 37 | return string.char(a_pos + math.random(0, 25)) 38 | end 39 | 40 | local random_string = function(min_length, max_length) 41 | local result = "" 42 | for _ = 1, math.random(min_length, max_length) do 43 | result = result .. random_letter() 44 | end 45 | return result 46 | end 47 | 48 | for _ = 0, 40 do 49 | table.insert(field_names, random_string(3, 11)) 50 | end 51 | 52 | local random_value 53 | random_value = function() 54 | local r = math.random() 55 | 56 | if r < .2 then 57 | return math.random() < .4 58 | elseif r < .5 then 59 | return random_string(2, 20) 60 | else 61 | return math.random() * 65536 62 | end 63 | end 64 | 65 | local random_table = function(min_fields_n, max_fields_n) 66 | local result = {} 67 | for _ = 1, math.random(min_fields_n, max_fields_n) do 68 | result[field_names[math.random(#field_names)]] = random_value() 69 | end 70 | return result 71 | end 72 | 73 | for i = 1, 10000 do 74 | if math.random() < 0.2 then 75 | data.entities[i] = random_table(1, 20) 76 | else 77 | data.entities[i] = nil 78 | end 79 | end 80 | 81 | 82 | -- logic -- 83 | 84 | local serializers = { 85 | { 86 | name = "ldump", 87 | serialize = ldump, 88 | deserialize = function(data) 89 | return load(data)() 90 | end, 91 | }, 92 | { 93 | name = "binser", 94 | serialize = binser.serialize, 95 | deserialize = binser.deserialize, 96 | }, 97 | { 98 | name = "bitser", 99 | serialize = bitser.dumps, 100 | deserialize = bitser.loads, 101 | } 102 | } 103 | 104 | local names = {} 105 | local serialize_ts = {} 106 | local deserialize_ts = {} 107 | local memory_usages = {} 108 | 109 | for i, serializer in ipairs(serializers) do 110 | names[i] = serializer.name 111 | 112 | local serialize_t = os.clock() 113 | local serialized = serializer.serialize(data) 114 | serialize_ts[i] = ("%.3f s"):format(os.clock() - serialize_t) 115 | 116 | local deserialize_t = os.clock() 117 | _ = serializer.deserialize(serialized) 118 | deserialize_ts[i] = ("%.3f s"):format(os.clock() - deserialize_t) 119 | 120 | memory_usages[i] = ("%.3f KB"):format(#serialized / 1024) 121 | end 122 | 123 | local headers = {"serializer", "serialize time", "deserialize time", "memory"} 124 | 125 | print(utils.render_table(headers, {names, serialize_ts, deserialize_ts, memory_usages})) 126 | -------------------------------------------------------------------------------- /benchmark/utils.lua: -------------------------------------------------------------------------------- 1 | local utils = {} 2 | 3 | utils.render_table = function(headers, columns) 4 | local result = "| " 5 | local column_sizes = {} 6 | for i, header in ipairs(headers) do 7 | column_sizes[i] = #header 8 | for _, value in ipairs(columns[i]) do 9 | column_sizes[i] = math.max(column_sizes[i], #value) 10 | end 11 | 12 | result = result .. header .. string.rep(" ", column_sizes[i] - #header) .. " | " 13 | end 14 | 15 | result = result .. "\n| " 16 | 17 | for i, size in ipairs(column_sizes) do 18 | result = result .. string.rep("-", size) .. " | " 19 | end 20 | 21 | result = result .. "\n| " 22 | 23 | for i = 1, #columns[1] do 24 | if i > 1 then 25 | result = result .. "\n| " 26 | end 27 | for j = 1, #columns do 28 | local v = columns[j][i] 29 | result = result .. v .. string.rep(" ", column_sizes[j] - #v) .. " | " 30 | end 31 | end 32 | 33 | return result 34 | end 35 | 36 | return utils 37 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## `ldump` 4 | 5 | ```lua 6 | ldump(value: any) -> string 7 | ``` 8 | 9 | Serialize the given value to a string, that can be deserialized via `load` 10 | 11 | ### Example 12 | 13 | ```lua 14 | local upvalue = 42 15 | local world = { 16 | name = "New world", 17 | get_answer = function() return upvalue end, 18 | } 19 | 20 | local serialized_data = ldump(world) -- serialize to a string 21 | local loaded_world = load(serialized_data)() -- deserialize the string 22 | ``` 23 | 24 | See as a test at [/tests/test_use_case:12](/tests/test_use_case#L12) 25 | 26 | ### Example 27 | 28 | ```lua 29 | local ldump = require("ldump") 30 | 31 | local t = setmetatable({ 32 | creation_time = os.clock(), 33 | inner = coroutine.create(function() 34 | coroutine.yield(1) 35 | coroutine.yield(2) 36 | end) 37 | }, { 38 | __serialize = function(self) 39 | local creation_time = self.creation_time -- capturing upvalue 40 | return function() 41 | return { 42 | creation_time = creation_time, 43 | inner = coroutine.create(function() 44 | coroutine.yield(1) 45 | coroutine.yield(2) 46 | end) 47 | } 48 | end 49 | end, 50 | }) 51 | 52 | local serialized_data = ldump(t) 53 | local t_copy = load(serialized_data)() 54 | ``` 55 | 56 | See as a test at [/tests/test_use_case:71](/tests/test_use_case#L71) 57 | 58 | ## `ldump.serializer` 59 | 60 | ```lua 61 | ldump.serializer(x: any) -> (string | fun(): any)?, string? 62 | ``` 63 | 64 | Function, encapsulating custom serialization logic. 65 | 66 | Defined by default to work with `__serialize` and `.handlers`, can be reassigned. Accepts the serialized value, returns a deserializer in the form of a string with a valid lua expression, a function or nil if the value should be serialized normally. Also may return a second optional result -- a string to be displayed in the error message. 67 | 68 | ## `ldump.serializer.handlers` 69 | 70 | ```lua 71 | ldump.serializer.handlers: table 72 | ``` 73 | 74 | Custom serialization functions for the exact objects. 75 | 76 | Key is the value that can be serialized, value is a deserializer in the form of a string with a valid lua expression or a function. Takes priority over `__serialize`. 77 | 78 | ## `ldump.get_safe_env` 79 | 80 | ```lua 81 | ldump.get_safe_env() -> table 82 | ``` 83 | 84 | Get the environment for safe `load`ing. 85 | 86 | Intended to be passed as `env` argument when `load`ing untrusted data to prevent malicious code execution. Contains only functions, required by ldump itself -- if serialization is overridden, may need to be updated with the environment used there. 87 | 88 | ## `ldump.get_warnings` 89 | 90 | ```lua 91 | ldump.get_warnings() -> string[] 92 | ``` 93 | 94 | Get the list of warnings from the last ldump call. 95 | 96 | See [`ldump.strict_mode`](#ldumpstrict_mode) 97 | 98 | ## `ldump.ignore_upvalue_size` 99 | 100 | ```lua 101 | ldump.ignore_upvalue_size(f: T) -> T 102 | ``` 103 | 104 | Mark function, causing dump to stop producing upvalue size warnings. 105 | 106 | Upvalues can cause large modules to be serialized implicitly. Warnings allow tracking that. Returns the same function. 107 | 108 | ## `ldump.strict_mode` 109 | 110 | ```lua 111 | ldump.strict_mode: boolean = true 112 | ``` 113 | 114 | If true (by default), `ldump` treats unserializable data as an error, if false produces a warning and replaces data with nil. 115 | 116 | ## `ldump.preserve_modules` 117 | 118 | ```lua 119 | ldump.preserve_modules: boolean = false 120 | ``` 121 | 122 | If true (false by default), `ldump` will serialize modules through `require`. 123 | 124 | Allows to avoid serializing the modules, captured as upvalues in functions. Works only on the modules themselves, not on the values within. Is overall safe, as Lua itself caches modules the same way. 125 | 126 | ## `ldump.require_path` 127 | 128 | ```lua 129 | ldump.require_path: string 130 | ``` 131 | 132 | `require`-style path to the ldump module, used in deserialization. 133 | 134 | Inferred from requiring the ldump itself, can be changed. 135 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | Most of the development happened in [girvel/fallen](https://github.com/girvel/fallen), this repository was created afterwards. 4 | 5 | ## Testing 6 | 7 | Via [busted](https://github.com/lunarmodules/busted): 8 | 9 | ```bash 10 | busted 11 | ``` 12 | 13 | Also may be run to test `ldump.get_safe_env` for loading on all available test cases: 14 | 15 | ```bash 16 | LDUMP_TEST_SAFETY=1 busted 17 | ``` 18 | 19 | ## Plans 20 | 21 | Serialization in dynamic languages such as Lua (especially serializing upvalues) has an issue of capturing large chunks of data from modules and libraries, growing the size of the output and causing issues with `==`. [girvel/fallen](https://github.com/girvel/fallen) handles this problem through the [module](https://github.com/girvel/fallen/blob/master/lib/module.lua) module, explicitly marking all static data (see [tech.sound](https://github.com/girvel/fallen/blob/master/tech/sound.lua)). This solution seems too verbose and causes boilerplate; right now, an attempt to write a better solution is in progress (see [2.0 milestone](https://github.com/girvel/ldump/milestone/2)). 22 | -------------------------------------------------------------------------------- /docs/overloading.md: -------------------------------------------------------------------------------- 1 | # Overloading serialization 2 | 3 | There are two ways to customize serialization in `ldump`: by defining serializers for certain values or all the values with a certain metatable, or by reassigning a global preprocessing function that may choose to override or not override serialization for any data. Any serialization override would work recursively, so the serialization inside of the composite data (s. a. tables and closures) would use the override. 4 | 5 | ## 0. Method 6 | 7 | Any serialization function should return a deserializer. It may come in two forms: 8 | 9 | 1. (The main way) a function, that rebuilds the value back 10 | 11 | ```lua 12 | local produce_userdata = function() end -- placeholder for external function 13 | 14 | local userdata_container_mt 15 | userdata_container_mt = { 16 | __serialize = function(self) 17 | -- capture normal, serializable data as an upvalue 18 | local normal_data_copy = self.normal_data 19 | 20 | -- return the deserializer 21 | return function() 22 | return setmetatable({ 23 | normal_data = normal_data_copy, 24 | userdata = produce_userdata() 25 | }, userdata_container_mt) 26 | end 27 | end 28 | } 29 | 30 | local value = setmetatable({ 31 | userdata = produce_userdata(), 32 | normal_data = 42, 33 | }, userdata_container_mt) 34 | 35 | local serialized_data = ldump(value) 36 | local value_copy = load(serialized_data)() 37 | ``` 38 | 39 | Here the deserializer function is: 40 | 41 | ```lua 42 | function() 43 | return setmetatable({ 44 | normal_data = normal_data_copy, 45 | userdata = produce_userdata() 46 | }, userdata_container_mt) 47 | end 48 | ``` 49 | 50 | It captures the serializable data from the original using upvalues and recreates non-serializable data manually. 51 | 52 | See as a test at [/tests/test_use_case.lua:124](/tests/test_use_case.lua#L124) 53 | 54 | 2. The form of the string containing a valid lua expression (or a chunk), that when passed to `load` would produce the desired value, such as `"42"`, `"2 + 2"`, `"require('utf8')"` or any other valid expression. 55 | 56 | ## 1. Custom serializers 57 | 58 | Custom serializers can be defined in two ways: through the `__serialize` metamethod or `ldump.serializer.handlers`. 59 | 60 | ### `__serialize` 61 | 62 | Allows to redefine serialization for all values with the given metatable. The serialize metamethod should accept one argument — the object itself — and return the deserializer. See examples in [Method](#0-method). 63 | 64 | ### `ldump.serializer.handlers` 65 | 66 | Allows to redefine serialization for certain values. To do so, assign the handler with the value as a key and the deserializer as the value. See [API#`ldump.serializer.handlers`](/docs/api.md#ldumpserializerhandlers). 67 | 68 | ## 2. Custom preprocess 69 | 70 | You can reassign the `ldump.serializer` itself. It would be called before serializing each value and allow customizing serialization in the most flexible way. See signature at [API#`ldump.serializer`](/docs/api.md#ldumpserializer). 71 | -------------------------------------------------------------------------------- /docs/safety.md: -------------------------------------------------------------------------------- 1 | # Safety 2 | 3 | `ldump` is intended for serialization of all Lua types, including functions and metatables -- which are inherently unsafe. In untrusted data, there are two sources of danger: 4 | 5 | 1. `load(data)()` itself may run malicious code 6 | 2. Any manipulation with resulting data may run malicious code -- even indexing, assigning or using any operator -- due to metamethods 7 | 8 | There is a way to make it somewhat safe. This way wouldn't work in 5.1 and would be very bothersome in LuaJIT. 9 | 10 | ```lua 11 | local result = load(malicious_data, nil, nil, ldump.get_safe_env())() 12 | ``` 13 | 14 | Providing `ldump.get_safe_env()` as the fourth argument to `load` will limit access to lua environment for loaded data -- during both deserialization and usage. On LuaJIT, however, this will only prevent malicious code from being executed during the deserialization stage, so the only way to work with untrusted data safely would be to use `rawget` and `rawset` only. 15 | 16 | If you are customizing serialization, you will need to extend `ldump.get_safe_env`'s result with used lua environment members. 17 | 18 | **NOTE:** Lua 5.1's `setfenv` wouldn't make it safer 19 | -------------------------------------------------------------------------------- /docs/serializing_modules.md: -------------------------------------------------------------------------------- 1 | # Serializing modules 2 | 3 | Lua treats modules as functions, returning normal data (in contrast with static languages). For a serializer, there is little difference between data from a module and normal data generated at runtime. This leads to serializing library contents with the data referencing it, which is an expected behaviour (and default for ldump), but, generally, an undesirable one. 4 | 5 | ## Setting [`preserve_modules`](/docs/api.md#ldumppreserve_modules) flag 6 | 7 | If `ldump.preserve_modules` is set to `true`, any reference to any module in the serialized data would be set to deserialize through `require`. It would work only with the modules themselves, not with the data inside them. For example, here no library code will be serialized: 8 | 9 | ```lua 10 | local library = require("library") 11 | 12 | local f = function() 13 | return library.foo() .. library.bar() 14 | end 15 | 16 | _ = ldump(f) 17 | ``` 18 | 19 | But here it will: 20 | 21 | ```lua 22 | local library = require("library") 23 | local foo = library.foo 24 | local bar = library.bar 25 | 26 | local f = function() 27 | return foo() .. bar() 28 | end 29 | 30 | _ = ldump(f) 31 | ``` 32 | -------------------------------------------------------------------------------- /init.lua: -------------------------------------------------------------------------------- 1 | local warnings, allowed_big_upvalues, stack, handle_primitive, cache_packages 2 | 3 | -- API -- 4 | 5 | local ldump_mt = {} 6 | 7 | --- Serialization library, can be called directly. 8 | --- Serialize the given value to a string, that can be deserialized via `load`. 9 | --- @overload fun(value: any): string 10 | local ldump = setmetatable({}, ldump_mt) 11 | 12 | --- @alias deserializer string | fun(): any 13 | 14 | -- no fun overload, lua ls bugs out here 15 | 16 | --- Function, encapsulating custom serialization logic. 17 | --- 18 | --- Defined by default to work with `__serialize` and `.handlers`, can be reassigned. Accepts the 19 | --- serialized value, returns a deserializer in the form of a string with a valid lua expression, a 20 | --- function or nil if the value should be serialized normally. Also may return a second optional 21 | --- result -- a string to be displayed in the error message. 22 | ldump.serializer = setmetatable({ 23 | --- Custom serialization functions for the exact objects. 24 | --- 25 | --- Key is the value that can be serialized, value is a deserializer in the form of a string with a 26 | --- valid lua expression or a function. Takes priority over `__serialize`. 27 | --- @type table 28 | handlers = {}, 29 | }, { 30 | __call = function(self, x) 31 | local handler = self.handlers[x] 32 | if handler then 33 | return handler, "`ldump.serializer.handlers`" 34 | end 35 | 36 | local mt = getmetatable(x) 37 | handler = mt and mt.__serialize and mt.__serialize(x) 38 | if handler then 39 | return handler, "`getmetatable(x).__serialize(x)`" 40 | end 41 | end, 42 | }) 43 | 44 | --- Get the environment for safe `load`ing. 45 | --- 46 | --- Intended to be passed as `env` argument when `load`ing untrusted data to prevent malicious code 47 | --- execution. Contains only functions, required by ldump itself -- if serialization is overridden, 48 | --- may need to be updated with the environment used there. 49 | ldump.get_safe_env = function() 50 | return { 51 | load = load, 52 | loadstring = loadstring, 53 | debug = { 54 | setupvalue = debug.setupvalue, 55 | upvaluejoin = debug.upvaluejoin, 56 | }, 57 | setmetatable = setmetatable, 58 | } 59 | end 60 | 61 | --- Get the list of warnings from the last ldump call. 62 | --- 63 | --- See `ldump.strict_mode`. 64 | --- @return string[] 65 | ldump.get_warnings = function() return {unpack(warnings)} end 66 | 67 | --- Mark function, causing dump to stop producing upvalue size warnings. 68 | --- 69 | --- Upvalues can cause large modules to be serialized implicitly. Warnings allow tracking that. 70 | --- @generic T: function 71 | --- @param f T 72 | --- @return T # returns the same function 73 | ldump.ignore_upvalue_size = function(f) 74 | allowed_big_upvalues[f] = true 75 | return f 76 | end 77 | 78 | --- If true (by default), `ldump` treats unserializable data as an error, if false produces a 79 | --- warning and replaces data with nil. 80 | --- @type boolean 81 | ldump.strict_mode = true 82 | 83 | --- If true (false by default), `ldump` will serialize modules through `require`. 84 | --- 85 | --- Allows to avoid serializing the modules, captured as upvalues in functions. Works only on the 86 | --- modules themselves, not on the values within. Is overall safe, as Lua itself caches modules the 87 | --- same way. 88 | --- @type boolean 89 | ldump.preserve_modules = false 90 | 91 | --- `require`-style path to the ldump module, used in deserialization. 92 | --- 93 | --- Inferred from requiring the ldump itself, can be changed. 94 | --- @type string 95 | ldump.require_path = select(1, ...) 96 | 97 | 98 | -- internal implementation -- 99 | 100 | -- NOTICE: lua5.1-compatible; does not support goto 101 | unpack = unpack or table.unpack 102 | if _VERSION == "Lua 5.1" then 103 | load = loadstring 104 | end 105 | 106 | ldump_mt.__call = function(self, x) 107 | assert( 108 | self.require_path, 109 | "Put the lua path to ldump libary into ldump.require_path before calling ldump itself" 110 | ) 111 | 112 | stack = {} 113 | warnings = {} 114 | if ldump.preserve_modules then 115 | cache_packages() 116 | end 117 | local ok, result = pcall(handle_primitive, x, {size = 0}, {}) 118 | 119 | if not ok then 120 | error(result, 2) 121 | end 122 | 123 | local base_code = [[ 124 | local cache = {} 125 | local ldump 126 | if require then 127 | ldump = require("%s") 128 | else 129 | ldump = { 130 | ignore_upvalue_size = function() end 131 | } 132 | end 133 | return %s 134 | ]] 135 | 136 | return base_code:format(self.require_path, result) 137 | end 138 | 139 | allowed_big_upvalues = {} 140 | 141 | local to_expression = function(statement) 142 | return ("(function()\n%s\nend)()"):format(statement) 143 | end 144 | 145 | local build_table = function(x, cache, upvalue_id_cache) 146 | local mt = getmetatable(x) 147 | 148 | cache.size = cache.size + 1 149 | cache[x] = cache.size 150 | 151 | local result = {} 152 | result[1] = "local _ = {}" 153 | result[2] = ("cache[%s] = _"):format(cache.size) 154 | 155 | for k, v in pairs(x) do 156 | table.insert(stack, tostring(k)) 157 | table.insert(result, ("_[%s] = %s"):format( 158 | handle_primitive(k, cache, upvalue_id_cache), 159 | handle_primitive(v, cache, upvalue_id_cache) 160 | )) 161 | table.remove(stack) 162 | end 163 | 164 | if not mt then 165 | table.insert(result, "return _") 166 | else 167 | table.insert(result, ("return setmetatable(_, %s)") 168 | :format(handle_primitive(mt, cache, upvalue_id_cache))) 169 | end 170 | 171 | return table.concat(result, "\n") 172 | end 173 | 174 | local build_function = function(x, cache, upvalue_id_cache) 175 | cache.size = cache.size + 1 176 | local x_cache_i = cache.size 177 | cache[x] = x_cache_i 178 | 179 | local result = {} 180 | 181 | local ok, res = pcall(string.dump, x) 182 | 183 | if not ok then 184 | local message = ( 185 | "Function .%s is not `string.dump`-compatible; it likely uses coroutines; to serialize " .. 186 | "it properly, use `ldump.serializer.handlers`" 187 | ):format(table.concat(stack, ".")) 188 | 189 | if ldump.strict_mode then 190 | error(message, 0) 191 | else 192 | table.insert(warnings, message) 193 | return "nil" 194 | end 195 | end 196 | 197 | result[1] = "local _ = " .. ([[load(%q)]]):format(res) 198 | result[2] = ("cache[%s] = _"):format(x_cache_i) 199 | 200 | if allowed_big_upvalues[x] then 201 | result[3] = "ldump.ignore_upvalue_size(_)" 202 | end 203 | 204 | for i = 1, math.huge do 205 | local k, v = debug.getupvalue(x, i) 206 | if not k then break end 207 | 208 | table.insert(stack, (""):format(k)) 209 | local upvalue 210 | if 211 | k == "_ENV" 212 | and _ENV ~= nil -- in versions without _ENV, upvalue _ENV is always just a normal upvalue 213 | and v._G == _G -- for some reason, it may be that v ~= _ENV, but v._G == _ENV 214 | then 215 | upvalue = "_ENV" 216 | else 217 | upvalue = handle_primitive(v, cache, upvalue_id_cache) 218 | end 219 | table.remove(stack) 220 | 221 | if not allowed_big_upvalues[x] and #upvalue > 2048 and k ~= "_ENV" then 222 | table.insert(warnings, ("Big upvalue %s in %s"):format(k, table.concat(stack, "."))) 223 | end 224 | table.insert(result, ("debug.setupvalue(_, %s, %s)"):format(i, upvalue)) 225 | 226 | if debug.upvalueid then 227 | local id = debug.upvalueid(x, i) 228 | local pair = upvalue_id_cache[id] 229 | if pair then 230 | local f_i, upvalue_i = unpack(pair) 231 | table.insert( 232 | result, 233 | ("debug.upvaluejoin(_, %s, cache[%s], %s)"):format(i, f_i, upvalue_i) 234 | ) 235 | else 236 | upvalue_id_cache[id] = {x_cache_i, i} 237 | end 238 | end 239 | end 240 | table.insert(result, "return _") 241 | return table.concat(result, "\n") 242 | end 243 | 244 | local primitives = { 245 | number = function(x) 246 | return tostring(x) 247 | end, 248 | string = function(x) 249 | return string.format("%q", x) 250 | end, 251 | ["function"] = function(x, cache, upvalue_id_cache) 252 | return to_expression(build_function(x, cache, upvalue_id_cache)) 253 | end, 254 | table = function(x, cache, upvalue_id_cache) 255 | return to_expression(build_table(x, cache, upvalue_id_cache)) 256 | end, 257 | ["nil"] = function() 258 | return "nil" 259 | end, 260 | boolean = function(x) 261 | return tostring(x) 262 | end, 263 | } 264 | 265 | local package_cache = {} 266 | 267 | local REFERENCE_TYPES = { 268 | table = true, 269 | ["function"] = true, 270 | userdata = true, 271 | thread = true, 272 | } 273 | 274 | handle_primitive = function(x, cache, upvalue_id_cache) 275 | local xtype = type(x) 276 | if REFERENCE_TYPES[xtype] then 277 | local cache_i = cache[x] 278 | if cache_i then 279 | return ("cache[%s]"):format(cache_i) 280 | end 281 | end 282 | 283 | do -- handle custom serialization 284 | local deserializer, source = ldump.serializer(x) 285 | 286 | if deserializer then 287 | local deserializer_type = type(deserializer) 288 | 289 | if deserializer_type ~= "string" and deserializer_type ~= "function" then 290 | error(("%s returned type %s for .%s; it should return string or function") 291 | :format(source or "ldump.serializer", deserializer_type, table.concat(stack, ".")), 0) 292 | end 293 | 294 | local expression 295 | if deserializer_type == "string" then 296 | expression = deserializer 297 | else 298 | allowed_big_upvalues[deserializer] = true 299 | expression = ("%s()"):format(handle_primitive(deserializer, cache, upvalue_id_cache)) 300 | end 301 | 302 | cache.size = cache.size + 1 303 | cache[x] = cache.size 304 | 305 | return to_expression(([[ 306 | local _ = %s 307 | cache[%s] = _ 308 | return _ 309 | ]]):format(expression, cache.size)) 310 | end 311 | end 312 | 313 | if not primitives[xtype] then 314 | local message = ( 315 | "ldump does not support serializing type %q of .%s; use `__serialize` metamethod or " .. 316 | "`ldump.serializer.handlers` to define serialization" 317 | ):format(xtype, table.concat(stack, ".")) 318 | 319 | if ldump.strict_mode then 320 | error(message, 0) 321 | end 322 | 323 | table.insert(warnings, message) 324 | return "nil" 325 | end 326 | 327 | if ldump.preserve_modules then 328 | local path = package_cache[x] 329 | if path then 330 | return ("require(%q)"):format(path) 331 | end 332 | end 333 | 334 | return primitives[xtype](x, cache, upvalue_id_cache) 335 | end 336 | 337 | cache_packages = function() 338 | for k, v in pairs(package.loaded) do 339 | package_cache[v] = k 340 | end 341 | end 342 | 343 | 344 | return ldump 345 | -------------------------------------------------------------------------------- /tests/_busted.lua: -------------------------------------------------------------------------------- 1 | --- @meta 2 | --- @diagnostic disable:inject-field 3 | 4 | --- @param description string 5 | --- @param body fun(): nil 6 | describe = function(description, body) end 7 | 8 | --- @param description string 9 | --- @param body fun(): nil 10 | it = function(description, body) end 11 | 12 | --- @param it any 13 | assert.is_true = function(it) end 14 | 15 | --- @param it any 16 | assert.is_false = function(it) end 17 | 18 | --- @param it nil 19 | assert.is_nil = function(it) end 20 | 21 | --- @generic T 22 | --- @param expected T 23 | --- @param received T 24 | assert.are_equal = function(expected, received) end 25 | 26 | --- @generic T 27 | --- @param expected T 28 | --- @param received T 29 | assert.are_not_equal = function(expected, received) end 30 | 31 | --- @generic T 32 | --- @param expected T 33 | --- @param received T 34 | assert.are_same = function(expected, received) end 35 | -------------------------------------------------------------------------------- /tests/resources/deterministic.lua: -------------------------------------------------------------------------------- 1 | return { 2 | some_value = {}, 3 | } 4 | -------------------------------------------------------------------------------- /tests/test_ldump.lua: -------------------------------------------------------------------------------- 1 | local ldump = require("init") 2 | local utils = require("tests.utils") 3 | 4 | _G.unpack = table.unpack or unpack 5 | _G.load = utils.load 6 | local pass = utils.pass 7 | 8 | 9 | describe("Serializing primitives:", function() 10 | local persists = function(value) 11 | assert.are_same(value, pass(value)) 12 | end 13 | 14 | it("nil", function() 15 | persists(nil) 16 | end) 17 | 18 | it("boolean", function() 19 | persists(true) 20 | end) 21 | 22 | it("number", function() 23 | persists(42) 24 | end) 25 | 26 | it("string", function() 27 | persists("abc\n") 28 | persists("\a\b\f\n\r\t\v\\\"\'") 29 | end) 30 | 31 | it("function", function() 32 | local f = function() 33 | return true 34 | end 35 | 36 | assert.are_equal(f(), pass(f())) 37 | end) 38 | 39 | it("table", function() 40 | persists({a = 1, b = 2}) 41 | end) 42 | end) 43 | 44 | describe("Serializing complex cases:", function() 45 | it("shallow table with strange keys", function() 46 | local t = {["function"] = 1, ["//"] = 2} 47 | assert.are_same(t, pass(t)) 48 | end) 49 | 50 | it("embedded tables", function() 51 | local t = {a = {a = 1}} 52 | assert.are_same(t, pass(t)) 53 | end) 54 | 55 | it("table with metatable", function() 56 | local t = setmetatable({value = 1}, {__call = function(self) return self.value end}) 57 | assert.are_equal(1, pass(t)()) 58 | end) 59 | 60 | it("function with upvalues (closure)", function() 61 | local a = 1 62 | local b = 2 63 | local f = function() return a + b end 64 | assert.are_equal(3, pass(f)()) 65 | end) 66 | end) 67 | 68 | describe("Serializing table graphs:", function() 69 | it("multiple references to the same table", function() 70 | local o = {} 71 | local t = {o = o, t = {o = o}} 72 | local result = pass(t) 73 | assert.are_same(t, result) 74 | assert.are_equal(result.t.o, result.o) 75 | end) 76 | 77 | it("multiple references to the same function", function() 78 | local f = function() end 79 | local t = {f = f, t = {}} 80 | t.t.f = f 81 | local result = pass(t) 82 | assert.are_equal(result.t.f, result.f) 83 | end) 84 | 85 | it("multiple references to the same function with upvalues", function() 86 | local upvalue = 0 87 | local f = function() return upvalue end 88 | local t = {f = f, t = {f = f}} 89 | local result = pass(t) 90 | assert.are_equal(result.t.f, result.f) 91 | end) 92 | 93 | it("circular references", function() 94 | local t = {a = {}, b = {}} 95 | t.a.b = t.b 96 | t.b.a = t.a 97 | local result = pass(t) 98 | assert.are_same(t, result) 99 | assert.are_equal(result.a.b, result.b) 100 | assert.are_equal(result.b.a, result.a) 101 | end) 102 | 103 | it("references to itself", function() 104 | local t = {} 105 | t.t = t 106 | local result = pass(t) 107 | assert.are_same(t, result) 108 | assert.are_equal(result, result.t) 109 | end) 110 | 111 | it("tables as keys", function() 112 | local t = {} 113 | t[t] = t 114 | local result = load(ldump(t))() 115 | -- assert.are_same(t, result) 116 | -- lol my library works better than busted 117 | assert.are_equal(result[result], result) 118 | end) 119 | end) 120 | 121 | describe("Overriding serialization:", function() 122 | it("metatable's __serialize returning string", function() 123 | local t = setmetatable({}, {__serialize = function(self) return [[1]] end}) 124 | assert.are_equal(1, pass(t)) 125 | end) 126 | 127 | it("metatable's __serialize returning function", function() 128 | local t = setmetatable({a = 1}, { 129 | __serialize = function(self) 130 | local a = self.a 131 | return function() 132 | return a 133 | end 134 | end 135 | }) 136 | 137 | assert.are_equal(1, pass(t)) 138 | end) 139 | 140 | it("custom serializer handlers", function() 141 | local t = {value = 1} 142 | ldump.serializer.handlers[t] = "1" 143 | assert.are_equal(1, pass(t)) 144 | ldump.serializer.handlers[t] = nil 145 | end) 146 | 147 | it("custom serializer handlers -- threads", function() 148 | local thread = coroutine.create(function() 149 | coroutine.yield() 150 | return 1 151 | end) 152 | ldump.serializer.handlers[thread] = "404" 153 | assert.are_equal(404, pass(thread)) 154 | ldump.serializer.handlers[thread] = nil 155 | end) 156 | 157 | it("custom serializer", function() 158 | -- make an independent ldump copy for mutation 159 | local old_serializer = ldump.serializer 160 | ldump.serializer = function(x) 161 | if type(x) == "thread" then 162 | return "404", "serializer's thread handling" 163 | end 164 | 165 | if type(x) == "function" then 166 | return 0, "serializer's table handling" 167 | end 168 | end 169 | 170 | local thread = coroutine.create(function() end) 171 | local t = {a = 1, b = 2, c = coroutine.create(function() end)} 172 | local f = function() end 173 | 174 | assert.are_equal(404, pass(thread)) 175 | assert.are_same({a = 1, b = 2, c = 404}, pass(t)) 176 | 177 | local ok, res = pcall(ldump --[[@as function]], f) 178 | assert.is_false(ok) 179 | local ending = "serializer's table handling" 180 | assert.are_equal(ending, res:sub(1, #ending)) 181 | 182 | ldump.serializer = old_serializer 183 | end) 184 | 185 | it("Caching with custom serializer", function() 186 | local f = coroutine.wrap(function() end) 187 | local to_serialize = {a = f, b = f} 188 | ldump.serializer.handlers[f] = function() return {} end 189 | 190 | local copy = pass(to_serialize) 191 | assert.are_equal(copy.a, copy.b) 192 | 193 | ldump.serializer.handlers[f] = nil 194 | end) 195 | end) 196 | 197 | describe("Error handling:", function() 198 | describe("excessively big upvalues", function() 199 | local upvalue = "" 200 | for _ = 1, 100 do 201 | upvalue = upvalue .. "AAAAAAAAAAAAAAAAAAAAA" 202 | end 203 | -- upvalue is of size 2100, which is exceeds the limit of 2048 204 | local f = function() 205 | return upvalue 206 | end 207 | 208 | it("produce warning by default", function() 209 | ldump(f) 210 | assert.are_equal(1, #ldump.get_warnings()) 211 | end) 212 | 213 | it("omit warning if marked with ignore_upvalue_size", function() 214 | ldump.ignore_upvalue_size(f) 215 | ldump(f) 216 | assert.are_equal(0, #ldump.get_warnings()) 217 | end) 218 | end) 219 | 220 | it("wrong serializer return type always causes an error", function() 221 | local t = setmetatable({}, {__serialize = function(self) 222 | return 42 223 | end}) 224 | 225 | local ok, result = pcall(ldump --[[@as function]], t) 226 | 227 | assert.is_false(ok) 228 | end) 229 | 230 | describe("unsupported type", function() 231 | local c = coroutine.create(function() end) 232 | 233 | it("causes an error in strict mode", function() 234 | local success = pcall(ldump --[[@as function]], c) 235 | assert.is_false(success) 236 | end) 237 | 238 | it("writes a warning in non-strict mode", function() 239 | ldump.strict_mode = false 240 | ldump(c) 241 | assert.are_equal(1, #ldump.get_warnings()) 242 | ldump.strict_mode = true 243 | end) 244 | end) 245 | 246 | describe("coroutine.wrap function", function() 247 | local f = coroutine.wrap(function() end) 248 | 249 | it("causes an error in strict mode", function() 250 | local ok = pcall(ldump --[[@as function]], f) 251 | assert.is_false(ok) 252 | end) 253 | 254 | it("writes a warning in non-strict mode", function() 255 | ldump.strict_mode = false 256 | ldump(f) 257 | assert.are_equal(1, #ldump.get_warnings()) 258 | ldump.strict_mode = true 259 | end) 260 | end) 261 | end) 262 | 263 | describe("Corner cases:", function() 264 | it("shared upvalue", function() 265 | local create_property 266 | create_property = function() 267 | local val = 0 268 | local get = function() return val end 269 | local set = function(v) val = v end 270 | local result = {get = get, set = set} 271 | 272 | if not debug.upvaluejoin then -- needed in Lua 5.1 273 | setmetatable(result, { 274 | __serialize = function(self) 275 | return create_property 276 | end, 277 | }) 278 | end 279 | 280 | return result 281 | end 282 | 283 | local elem = load(ldump(create_property()))() 284 | elem.set(5) 285 | assert.are_equal(5, elem.get()) 286 | elem.set(7) 287 | assert.are_equal(7, elem.get()) 288 | end) 289 | 290 | it("shared reference type upvalue", function() 291 | local create_property 292 | create_property = function() 293 | local val = {0} 294 | local get = function() return val[1] end 295 | local set = function(v) val[1] = v end 296 | return {get = get, set = set} 297 | end 298 | 299 | local elem = load(ldump(create_property()))() 300 | elem.set(5) 301 | assert.are_equal(5, elem.get()) 302 | elem.set(7) 303 | assert.are_equal(7, elem.get()) 304 | end) 305 | 306 | it("handles _ENV upvalue correctly", function() 307 | local f 308 | do 309 | local _ENV = {a = 1} 310 | --- @diagnostic disable-next-line:undefined-global 311 | f = function() return _ENV, a end 312 | end 313 | 314 | local g = pass(f) 315 | 316 | local env, value = f() 317 | local copy_env, copy_value = g() 318 | 319 | assert.are_same(env, copy_env) 320 | assert.are_equal(value, copy_value) 321 | end) 322 | end) 323 | -------------------------------------------------------------------------------- /tests/test_safety.lua: -------------------------------------------------------------------------------- 1 | if _VERSION == "Lua 5.1" and type(jit) ~= "table" then return end -- Lua 5.1 is not safe 2 | 3 | local ldump = require("init") 4 | 5 | it("On-load safety", function() 6 | local malicious_data = string.dump(function() 7 | print(123) 8 | end) 9 | 10 | local ok = pcall(assert(load(malicious_data, nil, nil, ldump.get_safe_env()))) 11 | assert.is_false(ok) 12 | end) 13 | 14 | it("Data safety", function() 15 | local malicious_data = ldump(setmetatable({}, { 16 | __index = function() 17 | print(123) 18 | end 19 | })) 20 | 21 | local deserialized = load(malicious_data, nil, nil, ldump.get_safe_env())() 22 | 23 | local ok 24 | if type(jit) == "table" then 25 | ok = not not rawget(deserialized, "innocent_looking_field") 26 | else 27 | ok = pcall(function() return deserialized.innocent_looking_field end) 28 | end 29 | 30 | assert.is_false(ok) 31 | end) 32 | -------------------------------------------------------------------------------- /tests/test_static.lua: -------------------------------------------------------------------------------- 1 | local ldump = require("init") 2 | local utils = require("tests.utils") 3 | 4 | _G.unpack = table.unpack 5 | local pass = utils.pass 6 | 7 | 8 | describe("ldump.preserve_modules", function() 9 | it("usage", function() 10 | ldump.preserve_modules = true 11 | local deterministic = require("tests.resources.deterministic") 12 | local f = function() return deterministic.some_value end 13 | local f_copy = pass(f) 14 | assert.are_equal(f(), f_copy()) 15 | ldump.preserve_modules = false 16 | end) 17 | 18 | it("not usage", function() 19 | local deterministic = require("tests.resources.deterministic") 20 | local f = function() return deterministic.some_value end 21 | local f_copy = pass(f) 22 | assert.are_not_equal(f(), f_copy()) 23 | end) 24 | 25 | it("not in upvalue", function() 26 | ldump.preserve_modules = true 27 | local deterministic = require("tests.resources.deterministic") 28 | local t = {value = deterministic} 29 | local t_copy = pass(t) 30 | assert.are_equal(t.value, t_copy.value) 31 | ldump.preserve_modules = false 32 | end) 33 | end) 34 | -------------------------------------------------------------------------------- /tests/test_use_case.lua: -------------------------------------------------------------------------------- 1 | -- NOTICE all tests in this file are represented in the corresponding files, and should be updated 2 | -- there each time they are updated here 3 | 4 | local utils = require("tests.utils") 5 | _G.unpack = table.unpack or unpack 6 | _G.load = utils.load 7 | 8 | 9 | describe("README.md", function() 10 | it("Basic use case", function() 11 | local ldump = require("init") 12 | 13 | local upvalue = 42 14 | local world = { 15 | name = "New world", 16 | get_answer = function() return upvalue end, 17 | } 18 | 19 | local serialized_data = ldump(world) -- serialize to a string 20 | local loaded_world = load(serialized_data)() -- deserialize the string 21 | 22 | assert.are_equal(world.name, loaded_world.name) 23 | assert.are_equal(world.get_answer(), loaded_world.get_answer()) 24 | end) 25 | 26 | it("Serializing any lua data", function() 27 | local ldump = require("init") 28 | 29 | -- basic tables 30 | local game_state = { 31 | player = {name = "Player"}, 32 | boss = {name = "Boss"}, 33 | } 34 | 35 | -- circular references & tables as keys 36 | game_state.deleted_entities = { 37 | [game_state.boss] = true, 38 | } 39 | 40 | -- functions even with upvalues 41 | local upvalue = 42 42 | game_state.get_answer = function() return upvalue end 43 | 44 | -- fundamentally non-serializable types if overridden 45 | local create_coroutine = function() 46 | return coroutine.wrap(function() 47 | coroutine.yield(1337) 48 | coroutine.yield(420) 49 | end) 50 | end 51 | 52 | -- override serialization 53 | game_state.coroutine = create_coroutine() 54 | ldump.serializer.handlers[game_state.coroutine] = create_coroutine 55 | 56 | local serialized_data = ldump(game_state) -- serialize 57 | local loaded_game_state = load(serialized_data)() -- deserialize 58 | 59 | -- assert 60 | assert.are_equal(game_state.get_answer(), loaded_game_state.get_answer()) 61 | assert.are_equal(game_state.coroutine(), loaded_game_state.coroutine()) 62 | assert.are_equal(game_state.coroutine(), loaded_game_state.coroutine()) 63 | 64 | assert.are_same(game_state.player, loaded_game_state.player) 65 | assert.are_same(game_state.boss, loaded_game_state.boss) 66 | assert.are_same( 67 | game_state.deleted_entities[game_state.boss], 68 | loaded_game_state.deleted_entities[loaded_game_state.boss] 69 | ) 70 | end) 71 | end) 72 | 73 | describe("docs/api.md", function() 74 | it("Using metatables for serialization override", function() 75 | local ldump = require("init") 76 | 77 | local t = setmetatable({ 78 | creation_time = os.clock(), 79 | inner = coroutine.create(function() 80 | coroutine.yield(1) 81 | coroutine.yield(2) 82 | end) 83 | }, { 84 | __serialize = function(self) 85 | local creation_time = self.creation_time -- capturing upvalue 86 | return function() 87 | return { 88 | creation_time = creation_time, 89 | inner = coroutine.create(function() 90 | coroutine.yield(1) 91 | coroutine.yield(2) 92 | end) 93 | } 94 | end 95 | end, 96 | }) 97 | 98 | local serialized_data = ldump(t) 99 | local t_copy = load(serialized_data)() 100 | 101 | assert.is_true(math.abs(t.creation_time - t_copy.creation_time) < 0.0001) 102 | assert.are_equal(coroutine.resume(t.inner), coroutine.resume(t_copy.inner)) 103 | assert.are_equal(coroutine.resume(t.inner), coroutine.resume(t_copy.inner)) 104 | end) 105 | 106 | it("Using serializer.handlers for serialization override", function() 107 | local ldump = require("init") 108 | 109 | local create_coroutine = function() 110 | return coroutine.create(function() 111 | coroutine.yield(1) 112 | coroutine.yield(2) 113 | end) 114 | end 115 | 116 | local c = create_coroutine() 117 | ldump.serializer.handlers[c] = create_coroutine 118 | local serialized_data = ldump(c) 119 | local c_copy = load(serialized_data)() 120 | 121 | assert.are_equal(coroutine.resume(c), coroutine.resume(c_copy)) 122 | assert.are_equal(coroutine.resume(c), coroutine.resume(c_copy)) 123 | end) 124 | end) 125 | 126 | describe("docs/overloading.md", function() 127 | it("Simple example of deserializer", function() 128 | local ldump = require("init") 129 | local produce_userdata = function() end -- placeholder for external function 130 | 131 | local userdata_container_mt 132 | userdata_container_mt = { 133 | __serialize = function(self) 134 | -- capture normal, serializable data as an upvalue 135 | local normal_data_copy = self.normal_data 136 | 137 | -- return the deserializer 138 | return function() 139 | return setmetatable({ 140 | normal_data = normal_data_copy, 141 | userdata = produce_userdata() 142 | }, userdata_container_mt) 143 | end 144 | end 145 | } 146 | 147 | local value = setmetatable({ 148 | userdata = produce_userdata(), 149 | normal_data = 42, 150 | }, userdata_container_mt) 151 | 152 | local serialized_data = ldump(value) 153 | local value_copy = load(serialized_data)() 154 | 155 | assert.are_same(value, value_copy) 156 | end) 157 | end) 158 | -------------------------------------------------------------------------------- /tests/utils.lua: -------------------------------------------------------------------------------- 1 | local ldump = require("init") 2 | 3 | 4 | local utils = {} 5 | 6 | if os.getenv("LDUMP_TEST_SAFETY") then 7 | local old_load 8 | if loadstring and type(jit) ~= "table" then 9 | old_load = loadstring 10 | else 11 | old_load = load 12 | end 13 | 14 | local env = ldump.get_safe_env() 15 | env.coroutine = coroutine 16 | 17 | utils.load = function(x) 18 | return old_load(x, nil, nil, env) 19 | end 20 | else 21 | utils.load = load 22 | end 23 | 24 | --- Serialize and deserialize 25 | --- @generic T 26 | --- @param value T 27 | --- @return T 28 | utils.pass = function(value) 29 | return load(ldump(value))() 30 | end 31 | 32 | return utils 33 | --------------------------------------------------------------------------------