From 3eafcab64ecaf8d00a9264b441e996825a6a31bd Mon Sep 17 00:00:00 2001
From: Lars Müller <34514239+appgurueu@users.noreply.github.com>
Date: Sat, 11 Jun 2022 20:00:26 +0200
Subject: Builtin: Redo serialize.lua (#11427)

Features:

* Support for arbitrary references, including self-referencing
* Short output, references "long" strings as a bonus
* Around the same speed, potentially slower if long, short keys are present
* Properly works with NaN and inf
---
 builtin/common/tests/serialize_spec.lua | 155 ++++++++++++++++++++++++++++----
 1 file changed, 136 insertions(+), 19 deletions(-)

(limited to 'builtin/common/tests')

diff --git a/builtin/common/tests/serialize_spec.lua b/builtin/common/tests/serialize_spec.lua
index 69b2b567c..ea79680d7 100644
--- a/builtin/common/tests/serialize_spec.lua
+++ b/builtin/common/tests/serialize_spec.lua
@@ -6,38 +6,92 @@ _G.setfenv = require 'busted.compatibility'.setfenv
 dofile("builtin/common/serialize.lua")
 dofile("builtin/common/vector.lua")
 
+-- Supports circular tables; does not support table keys
+-- Correctly checks whether a mapping of references ("same") exists
+-- Is significantly more efficient than assert.same
+local function assert_same(a, b, same)
+	same = same or {}
+	if same[a] or same[b] then
+		assert(same[a] == b and same[b] == a)
+		return
+	end
+	if a == b then
+		return
+	end
+	if type(a) ~= "table" or type(b) ~= "table" then
+		assert(a == b)
+		return
+	end
+	same[a] = b
+	same[b] = a
+	local count = 0
+	for k, v in pairs(a) do
+		count = count + 1
+		assert(type(k) ~= "table")
+		assert_same(v, b[k], same)
+	end
+	for _ in pairs(b) do
+		count = count - 1
+	end
+	assert(count == 0)
+end
+
+local x, y = {}, {}
+local t1, t2 = {x, x, y, y}, {x, y, x, y}
+assert.same(t1, t2) -- will succeed because it only checks whether the depths match
+assert(not pcall(assert_same, t1, t2)) -- will correctly fail because it checks whether the refs match
+
 describe("serialize", function()
+	local function assert_preserves(value)
+		local preserved_value = core.deserialize(core.serialize(value))
+		assert_same(value, preserved_value)
+	end
 	it("works", function()
-		local test_in = {cat={sound="nyan", speed=400}, dog={sound="woof"}}
-		local test_out = core.deserialize(core.serialize(test_in))
-
-		assert.same(test_in, test_out)
+		assert_preserves({cat={sound="nyan", speed=400}, dog={sound="woof"}})
 	end)
 
 	it("handles characters", function()
-		local test_in = {escape_chars="\n\r\t\v\\\"\'", non_european="θשׁ٩∂"}
-		local test_out = core.deserialize(core.serialize(test_in))
-		assert.same(test_in, test_out)
+		assert_preserves({escape_chars="\n\r\t\v\\\"\'", non_european="θשׁ٩∂"})
+	end)
+
+	it("handles NaN & infinities", function()
+		local nan = core.deserialize(core.serialize(0/0))
+		assert(nan ~= nan)
+		assert_preserves(math.huge)
+		assert_preserves(-math.huge)
 	end)
 
 	it("handles precise numbers", function()
-		local test_in = 0.2695949158945771
-		local test_out = core.deserialize(core.serialize(test_in))
-		assert.same(test_in, test_out)
+		assert_preserves(0.2695949158945771)
 	end)
 
 	it("handles big integers", function()
-		local test_in = 269594915894577
-		local test_out = core.deserialize(core.serialize(test_in))
-		assert.same(test_in, test_out)
+		assert_preserves(269594915894577)
 	end)
 
 	it("handles recursive structures", function()
 		local test_in = { hello = "world" }
 		test_in.foo = test_in
+		assert_preserves(test_in)
+	end)
+
+	it("handles cross-referencing structures", function()
+		local test_in = {
+			foo = {
+				baz = {
+					{}
+				},
+			},
+			bar = {
+				baz = {},
+			},
+		}
 
-		local test_out = core.deserialize(core.serialize(test_in))
-		assert.same(test_in, test_out)
+		test_in.foo.baz[1].foo = test_in.foo
+		test_in.foo.baz[1].bar = test_in.bar
+		test_in.bar.baz[1] = test_in.foo.baz[1]
+
+		assert_preserves(test_in)
 	end)
 
 	it("strips functions in safe mode", function()
@@ -47,6 +101,7 @@ describe("serialize", function()
 			end,
 			foo = "bar"
 		}
+		setfenv(test_in.func, _G)
 
 		local str = core.serialize(test_in)
 		assert.not_nil(str:find("loadstring"))
@@ -58,13 +113,75 @@ describe("serialize", function()
 
 	it("vectors work", function()
 		local v = vector.new(1, 2, 3)
-		assert.same({{x = 1, y = 2, z = 3}}, core.deserialize(core.serialize({v})))
-		assert.same({x = 1, y = 2, z = 3}, core.deserialize(core.serialize(v)))
+		assert_preserves({v})
+		assert_preserves(v)
 
 		-- abuse
 		v = vector.new(1, 2, 3)
 		v.a = "bla"
-		assert.same({x = 1, y = 2, z = 3, a = "bla"},
-				core.deserialize(core.serialize(v)))
+		assert_preserves(v)
+	end)
+
+	it("handles keywords as keys", function()
+		assert_preserves({["and"] = "keyword", ["for"] = "keyword"})
+	end)
+
+	describe("fuzzing", function()
+		local atomics = {true, false, math.huge, -math.huge} -- no NaN or nil
+		local function atomic()
+			return atomics[math.random(1, #atomics)]
+		end
+		local function num()
+			local sign = math.random() < 0.5 and -1 or 1
+			local val = math.random(0, 2^52)
+			local exp = math.random() < 0.5 and 1 or 2^(math.random(-120, 120))
+			return sign * val * exp
+		end
+		local function charcodes(count)
+			if count == 0 then return end
+			return math.random(0, 0xFF), charcodes(count - 1)
+		end
+		local function str()
+			return string.char(charcodes(math.random(0, 100)))
+		end
+		local primitives = {atomic, num, str}
+		local function primitive()
+			return primitives[math.random(1, #primitives)]()
+		end
+		local function tab(max_actions)
+			local root = {}
+			local tables = {root}
+			local function random_table()
+				return  tables[#tables == 1 and 1 or math.random(1, #tables)] -- luacheck: ignore
+			end
+			for _ = 1, math.random(1, max_actions) do
+				local tab = random_table()
+				local value
+				if math.random() < 0.5 then
+					if math.random() < 0.5 then
+						value = random_table()
+					else
+						value = {}
+						table.insert(tables, value)
+					end
+				else
+					value = primitive()
+				end
+				tab[math.random() < 0.5 and (#tab + 1) or primitive()] = value
+			end
+			return root
+		end
+		it("primitives work", function()
+			for _ = 1, 1e3 do
+				assert_preserves(primitive())
+			end
+		end)
+		it("tables work", function()
+			for _ = 1, 100 do
+				local fuzzed_table = tab(1e3)
+				assert_same(fuzzed_table, table.copy(fuzzed_table))
+				assert_preserves(fuzzed_table)
+			end
+		end)
 	end)
 end)
-- 
cgit v1.2.3