HostShell: stage postLog bodies via OS temp env, not tmpnam

tmpnam() on the Windows CRT returns a bare, CWD-relative name (e.g. \sb4c.2), and io.open on it fails with Permission denied when the game's working directory is not writable -- a Program Files (or otherwise protected) install. postLog then dies before curl runs: the mod reports a send failure and no bytes leave the machine (confirmed on a Windows install: "could not create request body: \sb4c.2: Permission denied").

Stage the request body under the OS temp contract instead: TEMP/TMP on Windows (always set, always per-user writable), TMPDIR with a /tmp fallback on POSIX. The transport stays on plain io/os -- no love.filesystem dependency.

Tests updated to mock os.getenv and assert the staged path sits under the temp dir; 10/10 checks pass.
This commit is contained in:
Shane McGovern
2026-08-16 03:29:33 +01:00
parent 3588a5f3fe
commit 4b7a4daf2c
2 changed files with 29 additions and 9 deletions
+16 -2
View File
@@ -429,8 +429,22 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
userAgent = userAgent or "gen1recomp" userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then if HostShell.haveCurl() then
-- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never -- io.popen is one-way on Lua/LuaJIT: its mode is "r" or "w", never
-- "rw". Stage the request body so the response can stay on a read pipe. -- "rw". Stage the request body so the response can stay on a read
local bodyPath = os.tmpname() -- pipe. The staging directory comes from the OS temp contract, never
-- tmpnam(): the CRT's tmpnam() can return a name relative to the process
-- working directory, and a game installed under Program Files has no
-- writable CWD -- io.open would fail before curl ever runs and postLog
-- would silently drop the send. TEMP/TMP are per-user writable on
-- Windows; TMPDIR (with /tmp fallback) covers POSIX. No love.filesystem:
-- the sandbox-era transport stays on plain io/os.
local function stagingPath()
local dir = os.getenv("TEMP") or os.getenv("TMP")
if not dir or dir == "" then dir = os.getenv("TMPDIR") or "/tmp" end
local sep = dir:find("\\") and "\\" or "/"
return dir .. sep .. ("gen1recomp-post-%d.tmp"):format(
(os.time() % 1000000) * 100 + math.random(0, 99))
end
local bodyPath = stagingPath()
local bodyFile, bodyOpenErr = io.open(bodyPath, "wb") local bodyFile, bodyOpenErr = io.open(bodyPath, "wb")
if not bodyFile then if not bodyFile then
pcall(os.remove, bodyPath) pcall(os.remove, bodyPath)
+13 -7
View File
@@ -12,13 +12,13 @@ local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__" local MARK = "\n__gen1recomp_http__"
local BODY_PATH = "/tmp/gen1recomp-postlog-body-test" local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
local URL = "https://logs.example.com/logs" local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n" local BODY = "debug log body\n"
local realOpen = io.open local realOpen = io.open
local realPopen = io.popen local realPopen = io.popen
local realTmpname = os.tmpname local realGetenv = os.getenv
local realRemove = os.remove local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl local realHaveCurl = HostShell.haveCurl
@@ -26,7 +26,12 @@ local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end HostShell.haveCurl = function() return true end
os.tmpname = function() return BODY_PATH end os.getenv = function(name)
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
return STAGE_DIR
end
return realGetenv(name)
end
os.remove = function(path) os.remove = function(path)
removedPath = path removedPath = path
return true return true
@@ -55,21 +60,22 @@ local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test
io.open = realOpen io.open = realOpen
io.popen = realPopen io.popen = realPopen
os.tmpname = realTmpname os.getenv = realGetenv
os.remove = realRemove os.remove = realRemove
HostShell.haveCurl = realHaveCurl HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err)) eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
eq(openedPath, BODY_PATH, "the request body is written to a temporary file") check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
eq(openedMode, "wb", "the temporary request body is opened for binary writing") eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged") eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode") eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil, check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary") "curl reads the staged body with --data-binary")
check(popenCommand:find(BODY_PATH, 1, true) ~= nil, check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
"curl receives the temporary body path") "curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil, check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line") "the log body is not placed directly in the command line")
eq(removedPath, BODY_PATH, "the temporary request body is removed") eq(removedPath, openedPath, "the staged request body is removed")
T.finish("host shell postlog") T.finish("host shell postlog")