-- T4: the mod sandbox (src/mods/Sandbox.lua).  A mod's own chunks run against
-- an environment with no io, no os beyond the clock, and no way to name a path
-- outside its own directory, so a mod cannot reach the player's filesystem.
-- Every case here is an escape a mod would actually try.

package.path = "./?.lua;./?/init.lua;" .. package.path

local T = require("tests.modkit")
local Manifest = require("src.mods.Manifest")
local Sandbox = require("src.mods.Sandbox")
local SafePath = require("src.mods.SafePath")

local function manifest(id, extra)
  return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
    .. '"api":2%s}'):format(id, id, extra or "")
end

-- what a probe reports back; pcall'd so one broken assumption does not take
-- the whole entry chunk down and hide the rest
local PROBE = [[
  local mod = ...
  local out = mod.exports
  out.io = io
  out.package = package
  out.dofile = dofile
  out.loadfile = loadfile
  out.setfenv = setfenv
  out.getfenv = getfenv
  out.debug = debug
  out.osGetenv = os.getenv
  out.osExecute = os.execute
  out.osRemove = os.remove
  out.osTime = type(os.time)
  out.stringOk = ("a"):rep(3)

  local function attempt(fn, ...)
    local ok, err = pcall(fn, ...)
    if ok then return false end
    return tostring(err)
  end
  out.requireIo = attempt(require, "io")
  out.requireOs = attempt(require, "os")
  out.requireDebug = attempt(require, "debug")
  out.requirePackage = attempt(require, "package")
  out.requireFfi = attempt(require, "ffi")
  out.requireLoveFs = attempt(require, "love.filesystem")
  out.requireSocket = attempt(require, "socket")
  -- called from a nested Lua frame rather than straight off pcall, which is
  -- the shape a stack-walking gate reads differently
  out.requireIoNested = attempt(function() return require("io") end)
  out.requireSemver = select(2, pcall(require, "src.mods.Semver"))

  out.loveFilesystem = attempt(function() return love.filesystem end)
  out.loveThread = attempt(function() return love.thread end)
  out.loveSystem = attempt(function() return love.system end)
  out.loveGraphics = type(love.graphics)
  out.loveAssign = attempt(function() love.filesystem = {} end)

  -- the multi-file pattern mods/timekeepers_hut uses: a chunk loaded from the
  -- mod's own source must inherit the sandbox, not the real globals
  local child = load("return io, os.getenv, _G")
  local childIo, childGetenv, childG = child()
  out.childIo = childIo
  out.childGetenv = childGetenv
  out.childSharesEnv = childG == _G

  out.readEscape = attempt(function() return mod:read("../../secret.txt") end)
  out.readAbsolute = attempt(function() return mod:read("/etc/hosts") end)
  out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end)
  out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end)
  out.readOwn = mod:read("data/note.txt")

  _G.SANDBOX_LEAK = "escaped"
  out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped"
  -- a mod stomping the standard library must not reach the engine
  table.insert = function() error("stomped") end
  string.format = function() error("stomped") end
]]

local FILES = {
  ["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"),
  ["mods/fix_sandbox/main.lua"] = PROBE,
  ["mods/fix_sandbox/data/note.txt"] = "own file",
}

local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) })
T.eq(#run.errors, 0,
  "the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
local out = run.loader.exports.fix_sandbox or {}

-- ------- the standard library a mod does not get

T.eq(out.io, nil, "io is absent from the mod environment")
T.eq(out.package, nil, "package is absent, so package.loaded is unreachable")
T.eq(out.dofile, nil, "dofile is absent")
T.eq(out.loadfile, nil, "loadfile is absent")
T.eq(out.setfenv, nil, "setfenv is absent, so a mod cannot swap its own env")
T.eq(out.getfenv, nil, "getfenv is absent, so a mod cannot read the real _G out")
T.eq(out.debug, nil, "the debug library is absent")
T.eq(out.osGetenv, nil, "os.getenv is absent -- it is how the report's exploit "
  .. "found the user's home directory")
T.eq(out.osExecute, nil, "os.execute is absent")
T.eq(out.osRemove, nil, "os.remove is absent")
T.eq(out.osTime, "function", "os.time still works: the clock is not the hole")
T.eq(out.stringOk, "aaa", "the safe standard library is intact")

-- ------- require, the one call that would undo all of the above

T.check(out.requireIo and out.requireIo:find("not available to mods", 1, true),
  "require(\"io\") is refused: " .. tostring(out.requireIo))
T.check(out.requireOs ~= false, "require(\"os\") is refused")
T.check(out.requireDebug ~= false, "require(\"debug\") is refused")
T.check(out.requirePackage ~= false, "require(\"package\") is refused")
T.check(out.requireFfi ~= false, "require(\"ffi\") is refused: it is arbitrary C")
T.check(out.requireLoveFs ~= false, "require(\"love.filesystem\") is refused")
T.check(out.requireIoNested ~= false,
  "require(\"io\") from a nested frame is refused the same way")
T.check(out.requireSocket and out.requireSocket:find("network", 1, true),
  "a network module names the permission it needs: " .. tostring(out.requireSocket))
T.eq(type(out.requireSemver), "table",
  "the supported engine requires still resolve")

-- ------- the love facade

T.check(out.loveFilesystem and out.loveFilesystem:find("mod.storage", 1, true),
  "love.filesystem is refused and names the replacement")
T.check(out.loveThread ~= false, "love.thread is refused: it opens a full Lua state")
T.check(out.loveSystem and out.loveSystem:find("mod.device:powerInfo()", 1, true),
  "love.system is refused and names the scoped power replacement")
T.eq(out.loveGraphics, "table", "the rest of love passes through")
T.check(out.loveAssign ~= false, "a mod cannot assign into the love facade")

-- ------- env propagation and isolation

T.eq(out.childIo, nil,
  "a chunk a mod load()s inherits the sandbox (5.1 would hand it the real _G)")
T.eq(out.childGetenv, nil, "the child chunk gets the same reduced os")
T.check(out.childSharesEnv, "the child chunk shares the mod's own globals table")
T.check(out.globalsAreOwn, "a mod's globals write to its own table")
T.eq(_G.SANDBOX_LEAK, nil, "and never reach the engine's _G")
T.eq(("%d"):format(1), "1",
  "a mod stomping string.format cannot reach the engine's copy")
do
  local probe = {}
  table.insert(probe, "still works")
  T.eq(probe[1], "still works",
    "nor table.insert -- each mod gets its own standard-library namespace")
end

-- ------- paths

T.check(out.readEscape and out.readEscape:find("must stay inside", 1, true),
  "mod:read cannot climb out of the mod directory: " .. tostring(out.readEscape))
T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path")
T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb")
T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb")
T.eq(out.readOwn, "own file", "and the mod's own files still read")
run.release()

-- ------- the grammar itself

for _, bad in ipairs({ "../x", "a/../../x", "/etc/hosts", "C:/Windows/x",
                       "..\\x", "a\\b", "..", ".", "" }) do
  T.eq(SafePath.safe(bad), nil, ("SafePath rejects %q"):format(bad))
end
T.eq(SafePath.safe("maps/NEW_BARK_TOWN.lua"), "maps/NEW_BARK_TOWN.lua",
  "an ordinary relative path passes")
T.eq(SafePath.safe("./main.lua"), "main.lua",
  "a leading ./ is normalized rather than rejected, so older manifests load")

-- ------- manifest paths are untrusted input too

T.check(not pcall(Manifest.validate,
  { id = "evil", name = "evil", version = "1.0.0", entry = "../../../evil.lua" }),
  "a manifest cannot point entry outside the mod directory")
T.check(not pcall(Manifest.validate,
  { id = "evil", name = "evil", version = "1.0.0", entry = "main.lua",
    options_schema = "../../options.lua" }),
  "nor options_schema")
T.check(pcall(Manifest.validate,
  { id = "fine", name = "fine", version = "1.0.0", entry = "main.lua" }),
  "an ordinary manifest still validates")

-- ------- bytecode

do
  local bad = {
    ["mods/fix_bytecode/manifest.json"] = manifest("fix_bytecode"),
    ["mods/fix_bytecode/main.lua"] = string.dump(function() end),
  }
  local bytecodeRun = T.sdk.loadMods({ "mods/fix_bytecode" },
    { fs = T.sdk.memfs(bad) })
  T.eq(#bytecodeRun.errors, 1, "a mod that ships bytecode fails to load")
  T.check(tostring(bytecodeRun.errors[1]):find("bytecode", 1, true),
    "and says why: " .. tostring(bytecodeRun.errors[1]))
  bytecodeRun.release()
end

-- ------- the sandbox is not opt-in

do
  local env = Sandbox.envFor({ modId = "probe" })
  T.eq(env.io, nil, "a bare Sandbox.envFor is already closed")
  T.eq(env._G, env, "_G points at the sandbox, not the real globals")
  T.check(not pcall(env.require, "io"), "and its require refuses io")
end

T.finish("sandbox")
