From 36b52f7bdf5781416e66378d913dd1f948195d06 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 29 Aug 2026 08:25:28 +0200 Subject: [PATCH] Protect Timeless Jewel cache integrity Reject empty cached and inflated payloads so headless stubs cannot create zero-byte caches. Write replacements through a checked temporary file and restore an existing cache when promotion fails. --- spec/System/TestDataJewelFileLoader_spec.lua | 154 +++++++++++++++++++ src/Modules/DataJewelFileLoader.lua | 72 ++++++++- 2 files changed, 220 insertions(+), 6 deletions(-) create mode 100644 spec/System/TestDataJewelFileLoader_spec.lua diff --git a/spec/System/TestDataJewelFileLoader_spec.lua b/spec/System/TestDataJewelFileLoader_spec.lua new file mode 100644 index 00000000000..1e0ab145543 --- /dev/null +++ b/spec/System/TestDataJewelFileLoader_spec.lua @@ -0,0 +1,154 @@ +describe("Data jewel file loader", function() + local originalConPrintf + local originalGetScriptPath + local originalInflate + local originalNewFileSearch + local originalOpen + local originalRemove + local originalRename + local files, modifiedTimeByPath + local writePath + local renameFile + local renameOverride + local loadJewelFile + local cachePath + local compressedFilePath + + local function setInputs(cacheData, cacheModified, inflatedData) + files[cachePath] = cacheData + modifiedTimeByPath[cachePath] = cacheModified + files[compressedFilePath] = "compressed data" + modifiedTimeByPath[compressedFilePath] = 10 + _G.Inflate = function() return inflatedData end + end + + before_each(function() + originalConPrintf = _G.ConPrintf + originalGetScriptPath = _G.GetScriptPath + originalInflate = _G.Inflate + originalNewFileSearch = _G.NewFileSearch + originalOpen = io.open + originalRemove = os.remove + originalRename = os.rename + files = { } + modifiedTimeByPath = { } + writePath = nil + cachePath = "./Data/TimelessJewelData/TestJewel.bin" + compressedFilePath = "./Data/TimelessJewelData/TestJewel.zip" + + _G.ConPrintf = function() end + _G.GetScriptPath = function() return "." end + _G.NewFileSearch = function(path) + if modifiedTimeByPath[path] == nil then + return + end + return { + GetFileName = function() return path end, + GetFileModifiedTime = function() return modifiedTimeByPath[path] end, + } + end + io.open = function(path, mode) + if mode == "rb" then + if files[path] == nil then + return + end + return { + read = function() return files[path] end, + close = function() return true end, + } + end + if mode == "wb" then + writePath = path + local pendingData + local file = { } + function file:write(data) + pendingData = data + return self + end + function file:close() + files[path] = pendingData or "" + return true + end + return file + end + end + renameFile = function(sourcePath, destinationPath) + if files[sourcePath] == nil then + return nil, "source does not exist" + end + files[destinationPath] = files[sourcePath] + files[sourcePath] = nil + return true + end + os.rename = function(sourcePath, destinationPath) + if renameOverride then + local handled, renameResult, renameError = renameOverride(sourcePath, destinationPath) + if handled then + return renameResult, renameError + end + end + return renameFile(sourcePath, destinationPath) + end + os.remove = function(path) + if files[path] == nil then + return nil, "file does not exist" + end + files[path] = nil + return true + end + + loadJewelFile = LoadModule("Modules/DataJewelFileLoader") + end) + + after_each(function() + _G.ConPrintf = originalConPrintf + _G.GetScriptPath = originalGetScriptPath + _G.Inflate = originalInflate + _G.NewFileSearch = originalNewFileSearch + io.open = originalOpen + os.remove = originalRemove + os.rename = originalRename + end) + + it("falls back from a newer zero-byte cache", function() + setInputs("", 20, "fresh jewel data") + + local jewelData = loadJewelFile("TestJewel") + + assert.are.equal("fresh jewel data", jewelData) + assert.are.equal("fresh jewel data", files[cachePath]) + assert.is_truthy(writePath:find(cachePath .. ".tmp.", 1, true)) + end) + + it("rejects empty inflation without opening the cache for writing", function() + setInputs("previous jewel data", 5, "") + + local jewelData = loadJewelFile("TestJewel") + + assert.is_nil(jewelData) + assert.are.equal("previous jewel data", files[cachePath]) + assert.is_nil(writePath) + end) + + it("restores the previous cache when promotion fails", function() + setInputs("previous jewel data", 5, "fresh jewel data") + local promotionAttempts = 0 + renameOverride = function(sourcePath, destinationPath) + if destinationPath == cachePath and sourcePath:find(cachePath .. ".tmp.", 1, true) == 1 then + promotionAttempts = promotionAttempts + 1 + return true, nil, "promotion failed" + end + return false + end + + local jewelData = loadJewelFile("TestJewel") + + assert.are.equal("fresh jewel data", jewelData) + assert.are.equal("previous jewel data", files[cachePath]) + assert.are.equal(2, promotionAttempts) + for path in pairs(files) do + assert.is_falsy(path:find(cachePath .. ".tmp.", 1, true)) + assert.is_falsy(path:find(cachePath .. ".backup.", 1, true)) + end + end) +end) diff --git a/src/Modules/DataJewelFileLoader.lua b/src/Modules/DataJewelFileLoader.lua index 07ce80e52eb..f2c10fc0594 100644 --- a/src/Modules/DataJewelFileLoader.lua +++ b/src/Modules/DataJewelFileLoader.lua @@ -5,6 +5,63 @@ -- local t_concat = table.concat +-- Keep temporary and backup paths beside the cache so renames stay on one +-- volume. A per-write suffix reduces collisions between concurrent writes. +local function makeSiblingPaths(cachePath) + local allocationAddressToken = tostring({ }):match("0x(%x+)") or "unknown" + local suffix = os.time() .. "." .. allocationAddressToken + return cachePath .. ".tmp." .. suffix, cachePath .. ".backup." .. suffix +end + +local function promoteCache(cachePath, temporaryPath, backupPath) + local promoted, promoteError = os.rename(temporaryPath, cachePath) + if promoted then + return true + end + + -- Windows cannot rename over an existing file. Move the current cache aside + -- before retrying so a failed replacement can restore the previous data. + local backedUp, backupError = os.rename(cachePath, backupPath) + if not backedUp then + os.remove(temporaryPath) + return nil, promoteError or backupError + end + + promoted, promoteError = os.rename(temporaryPath, cachePath) + if promoted then + local removed, removeError = os.remove(backupPath) + if not removed then + ConPrintf("Failed to remove jewel data cache backup " .. backupPath .. ": " .. tostring(removeError)) + end + return true + end + + local restored, restoreError = os.rename(backupPath, cachePath) + os.remove(temporaryPath) + if not restored then + return nil, tostring(promoteError) .. "; cache backup remains at " .. backupPath + .. " after restore failed: " .. tostring(restoreError) + end + return nil, promoteError +end + +local function writeCache(cachePath, jewelData) + local temporaryPath, backupPath = makeSiblingPaths(cachePath) + local temporaryFile, openError = io.open(temporaryPath, "wb") + if not temporaryFile then + return nil, openError + end + + local written, writeError = temporaryFile:write(jewelData) + local closed, closeError = temporaryFile:close() + if not written or not closed then + os.remove(temporaryPath) + return nil, writeError or closeError + end + + return promoteCache(cachePath, temporaryPath, backupPath) +end + local function loadJewelFile(jewelTypeName, cacheUncompressed) local jewelPath = "/Data/TimelessJewelData/" .. jewelTypeName local scriptPath = GetScriptPath() @@ -42,12 +99,12 @@ local function loadJewelFile(jewelTypeName, cacheUncompressed) end if uncompressedFileAttr.modified and uncompressedFileAttr.modified > (compressedFileAttr.modified or 0) then - ConPrintf("Uncompressed jewel data is up-to-date, loading " .. uncompressedFileAttr.fileName) local uncompressedFile = io.open(scriptPath .. jewelPath .. ".bin", "rb") if uncompressedFile then local jewelData = uncompressedFile:read("*a") uncompressedFile:close() - if jewelData then + if jewelData and jewelData ~= "" then + ConPrintf("Uncompressed jewel data is up-to-date, loading " .. uncompressedFileAttr.fileName) return jewelData end end @@ -82,11 +139,14 @@ local function loadJewelFile(jewelTypeName, cacheUncompressed) end local jewelData = Inflate(compressedData) + if not jewelData or jewelData == "" then + ConPrintf("Failed to inflate jewel data: " .. jewelTypeName) + return + end if cacheUncompressed then - local uncompressedFile = io.open(scriptPath .. jewelPath .. ".bin", "wb+") - if uncompressedFile then - uncompressedFile:write(jewelData) - uncompressedFile:close() + local cached, cacheError = writeCache(scriptPath .. jewelPath .. ".bin", jewelData) + if not cached then + ConPrintf("Failed to cache jewel data " .. jewelTypeName .. ": " .. tostring(cacheError)) end end return jewelData