retex-lua 1.3.0-alpha.2

Embedded Lua and the LuaTeX Lua API for retex
Documentation
-- os.execute and io.popen under LuaTeX's shell escape modes.
--
-- Behaviour observed with luatex: status.shell_escape is 0 (disabled),
-- 1 (enabled) or 2 (restricted to the commands in shell_escape_commands);
-- os.execute() returns that mode; os.execute(cmd) returns the raw wait
-- status (exit code * 256) or nil plus a message; io.popen refuses with
-- its own messages.
local output_directory, private_output_directory = ...
local execute, popen = os.execute, io.popen
local find, gsub, sub = string.find, string.gsub, string.sub

local function mode()
  return status.shell_escape
end

-- The words of a command line, honouring single and double quotes.
local function words(cmd)
  local out, cur, i, n, inword = {}, {}, 1, #cmd, false
  while i <= n do
    local c = sub(cmd, i, i)
    if c == "'" or c == '"' then
      local j = find(cmd, c, i + 1, true) or (n + 1)
      cur[#cur + 1] = sub(cmd, i + 1, j - 1)
      inword, i = true, j + 1
    elseif find(c, "^%s$") then
      if inword then out[#out + 1] = table.concat(cur); cur, inword = {}, false end
      i = i + 1
    else
      cur[#cur + 1] = c
      inword, i = true, i + 1
    end
  end
  if inword then out[#out + 1] = table.concat(cur) end
  return out
end

local function allowed(name)
  local list = kpse.var_value("shell_escape_commands") or ""
  for c in list:gmatch("[^,%s]+") do
    if c == name then return true end
  end
  return false
end

-- In restricted mode the command must be an allowed program, and every
-- word is passed quoted, so shell metacharacters cannot start another
-- command. A single quote anywhere is a quoting error (observed; double
-- quotes are fine): the second result is then "quote".
local function restricted(cmd)
  if find(cmd, "'", 1, true) then return nil, "quote" end
  local w = words(cmd)
  if #w == 0 or not allowed(w[1]) then return nil end
  for i = 1, #w do w[i] = "'" .. gsub(w[i], "'", "'\\''") .. "'" end
  return table.concat(w, " ")
end

-- With an output directory, commands get it in TEXMF_OUTPUT_DIRECTORY, as
-- TeX Live's engines set it for the programs they run (minted, for one,
-- looks for its files there).
-- os.setenv: the variables set are kept here (false for unset), seen by
-- os.getenv and exported to the commands run; the process environment
-- itself is not changed. Like LuaTeX's, it returns nil.
local env_set, getenv = {}, os.getenv

function os.setenv(key, value)
  env_set[key] = value ~= nil and tostring(value) or false
  return nil
end

function os.getenv(key)
  local v = env_set[key]
  if v ~= nil then return v or nil end
  -- LuaTeX sets TEXMF_OUTPUT_DIRECTORY from -output-directory.
  if key == "TEXMF_OUTPUT_DIRECTORY" then
    local dir = output_directory()
    if dir then return dir end
  end
  return getenv(key)
end

-- Programs that work on the job's auxiliary files.
local aux_tools = {
  makeindex = true, mendex = true, upmendex = true, xindy = true, texindy = true,
  bibtex = true, bibtex8 = true, bibtexu = true, biber = true,
}

local function quote(s)
  return "'" .. gsub(s, "'", "'\\''") .. "'"
end

local function with_output_directory(cmd)
  -- `unset K;` and `export K=v;` before the command.
  local unset, prefix = {}, {}
  for k, v in pairs(env_set) do
    if find(k, "^[%a_][%w_]*$") then
      if v then
        -- Exported, so that the command line itself can use it ($K).
        prefix[#prefix + 1] = "export " .. k .. "=" .. quote(v) .. "; "
      else
        unset[#unset + 1] = "unset " .. k .. "; "
      end
    end
  end
  table.sort(unset)
  table.sort(prefix)
  prefix = { table.concat(unset), table.concat(prefix) }
  -- Exported too, so that `$TEXMF_OUTPUT_DIRECTORY` in the command line
  -- has the value.
  local dir = output_directory()
  if dir and env_set.TEXMF_OUTPUT_DIRECTORY == nil then
    prefix[#prefix + 1] = "export TEXMF_OUTPUT_DIRECTORY=" .. quote(dir) .. "; "
  end
  -- With retex's own directory for auxiliary files, index and
  -- bibliography tools run there, where the files they read are (imakeidx
  -- runs `makeindex <job>.idx`); the document's directory stays in their
  -- search paths, for its styles and databases.
  local w = words(cmd)[1]
  if dir and private_output_directory() and w and aux_tools[w] then
    local cwd = getenv("PWD") or "."
    for _, v in ipairs({ "INDEXSTYLE", "BIBINPUTS", "BSTINPUTS" }) do
      prefix[#prefix + 1] = "export " .. v .. "=" .. quote(cwd .. ":") .. '"$' .. v .. '"; '
    end
    prefix[#prefix + 1] = "cd " .. quote(dir) .. " && "
  end
  return table.concat(prefix) .. cmd
end

local function wait_status(ok, how, code)
  if how == "exit" then return code * 256 end
  if how == "signal" then return code end
  return ok and 0 or 256
end

function os.execute(cmd)
  local m = mode()
  if cmd == nil then return m end
  if m == 0 then return nil, "All command execution disabled." end
  if m == 2 then
    local why
    cmd, why = restricted(cmd)
    if why then return nil, "Quoting error in system command line." end
    if not cmd then return nil, "Command execution disabled via shell_escape='p'" end
  end
  return wait_status(execute(with_output_directory(cmd)))
end

-- `merge_stderr`: also read what the command writes to its standard
-- error (retex's default mode keeps both out of the terminal).
local function do_popen(cmd, how, merge_stderr)
  local m = mode()
  if m == 0 then return nil, "all command execution is disabled" end
  if m == 2 then
    local why
    cmd, why = restricted(cmd)
    if why then return nil, "bad command line quoting" end
    if not cmd then return nil, "specific command execution disabled" end
  end
  cmd = with_output_directory(cmd)
  if merge_stderr then cmd = "{ " .. cmd .. "\n} 2>&1" end
  return popen(cmd, how)
end

function io.popen(cmd, how)
  return do_popen(cmd, how)
end

-- For retex's default mode: the command's output, standard error
-- included, from a pipe.
function os.retex_popen_all(cmd)
  return do_popen(cmd, "r", true)
end