analyzers/submit/nmap-dir/nselib/ls.lua
---
-- Report file and directory listings.
--
-- For scripts that gather and report directory listings, this script provides
-- a common output format and helpful script arguments.
--
-- The arguments can either be set for all the scripts using this
-- module (--script-args ls.arg=value) or for one particular script
-- (--script-args afp-ls.arg=value). If both are specified for the
-- same argument, the script-specific value is used.
--
-- @args ls.maxdepth The maximum depth to recurse into a directory. If less
--                   than 0 (e.g. -1) then recursion is unlimited.
--                   (default: 0, no recursion).
-- @args ls.maxfiles The maximum number of files to return. Set to 0 or less to
--                   disable this limit. (default: 10).
-- @args ls.checksum (boolean) Download each file and calculate a
--                   SHA1 checksum. Although this is a module
--                   argument, the implementation is done in each
--                   script and is currently only supported by smb-ls
--                   and http-ls
-- @args ls.errors   (boolean) Report errors
-- @args ls.empty    (boolean) Report empty volumes (with no information
--                   or error)
-- @args ls.human    (boolean) Show file sizes in human-readable format with K,
--                   M, G, T, P suffixes. Some services return human-readable
--                   sizes natively; in these cases, the size is reported as
--                   given.
--
-- @author Pierre Lalet <pierre@droids-corp.org>
-- @copyright Same as Nmap--See https://nmap.org/book/man-legal.html
-----------------------------------------------------------------------

local LIBRARY_NAME = "ls"

local math = require "math"
local stdnse = require "stdnse"
local string = require "string"
local tab = require "tab"
local table = require "table"

_ENV = stdnse.module("ls", stdnse.seeall)

local config_values = {
  ["maxdepth"] = 1,
  ["maxfiles"] = 10,
  ["checksum"] = false,
  ["errors"] = false,
  ["empty"] = false,
  ["human"] = false,
}

--- Convert an argument to its expected type
local function convert_arg(argval, argtype)
  if argtype == "number" then
    return tonumber(argval)
  elseif argtype == "boolean" then
    if argval == "false" or argval == "no" or argval == "0" then
      return false
    else
      return true
    end
  end
  return argval
end

--- Update config_values using module arguments ("ls.argname", as
-- opposed to script-specific arguments, "http-ls.argname")
for argname, argvalue in pairs(config_values) do
  local argval = stdnse.get_script_args(LIBRARY_NAME .. "." .. argname)
  if argval ~= nil then
    config_values[argname] = convert_arg(argval, type(argvalue))
  end
end

--- Get a config value from (by order or priority):
-- 1. a script-specific argument (e.g., http-ls.argname)
-- 2. a module argument (ls.argname)
-- 3. the default value
-- @param argname The name of the configuration parameter
-- @return The configuration value
function config(argname)
  local argval = stdnse.get_script_args(stdnse.getid() .. "." .. argname)
  if argval == nil then
    return config_values[argname]
  else
    return convert_arg(argval, type(config_values[argname]))
  end
end

--- Create a new script output.
-- @return The ls output object to be passed to other functions
function new_listing()
  local output = stdnse.output_table()
  output['curvol'] = nil
  output['volumes'] = {}
  output['errors'] = {}
  output['info'] = {}
  output['total'] = {
    ['files'] = 0,
    ['bytes'] = 0,
  }
  return output
end

--- Create a new volume within the provided output
-- @param output The ls output object, from new_listing()
-- @param name The name of the volume
-- @param hasperms Boolean true if the volume listing will include permissions
function new_vol(output, name, hasperms)
  local curvol = stdnse.output_table()
  local files = tab.new()
  local i = 1
  if hasperms then
    tab.add(files, 1, "PERMISSION")
    tab.add(files, 2, "UID")
    tab.add(files, 3, "GID")
    i = 4
  end
  tab.add(files, i, "SIZE")
  tab.add(files, i + 1, "TIME")
  tab.add(files, i + 2, "FILENAME")
  if config("checksum") then
    tab.add(files, i + 3, "CHECKSUM")
  end
  tab.nextrow(files)
  curvol['name'] = name
  curvol['files'] = files
  curvol['count'] = 0
  curvol['bytes'] = 0
  curvol['errors'] = {}
  curvol['info'] = {}
  curvol['hasperms'] = hasperms
  output['curvol'] = curvol
end

--- Report an error, using stdnse.debug() and (depending on the
-- configuration settings) adding the error message to the output.
-- @param output The ls output object, from new_listing()
-- @param err The error message to report
-- @param level The debug level (default: 1)
function report_error(output, err, level)
  level = level or 1
  if output["curvol"] == nil then
    stdnse.debug(level, string.format("error: %s", err))
  else
    stdnse.debug(level, string.format("error [%s]: %s",
      output["curvol"]["name"], err))
  end
  if config('errors') then
    if output["curvol"] == nil then
      table.insert(output["errors"], err)
    else
      table.insert(output["curvol"]["errors"], err)
    end
  end
end

--- Report information, using stdnse.debug() and adding the message
-- to the output.
-- @param output The ls output object, from new_listing()
-- @param info The info message to report
-- @param level The debug level (default: 1)
function report_info(output, info, level)
  level = level or 1
  if output["curvol"] == nil then
    stdnse.debug(level, string.format("info: %s", info))
    table.insert(output["info"], info)
  else
    stdnse.debug(level, string.format("info [%s]: %s",
      output["curvol"]["name"],  info))
    table.insert(output["curvol"]["info"], info)
  end
end

local units = {
  ["k"] = 1024,
  ["m"] = 1024^2,
  ["g"] = 1024^3,
  ["t"] = 1024^4,
  ["p"] = 1024^5,
}

--- Get a size as an integer from a (possibly) human readable input.
local function get_size(size)
  local bsize = tonumber(size)
  if bsize == nil then
    local unit = string.lower(string.sub(size, -1, -1))
    bsize = tonumber(string.sub(size, 0, -2))
    if units[unit] ~= nil and bsize ~= nil then
      bsize = bsize * units[unit]
      local sigfigs = #(string.match(size, "[0-9.]+"))
      if string.find(size, "%.") then
        sigfigs = sigfigs - 1
      end
      local d = math.ceil(math.log(bsize, 10))
      local power = sigfigs - d
      local magnitude = 10^power
      local shifted = math.floor(bsize * magnitude)
      bsize = math.floor(shifted / magnitude)
    else
      bsize = nil
    end
  end
  return bsize
end

--- Add a new file to the current volume.
-- @param output The ls output object, from new_listing()
-- @param file A table containing the information about the file
-- @return Boolean true if the script may continue adding files, false if
--         maxfiles has been reached.
function add_file(output, file)
  local curvol = output.curvol
  local files = curvol["files"]
  for i, info in ipairs(file) do
    tab.add(files, i, tostring(info))
  end
  local size = get_size(file[curvol.hasperms and 4 or 1])
  if size then
    curvol["bytes"] = curvol["bytes"] + size
  end
  tab.nextrow(files)
  curvol["count"] = curvol["count"] + 1
  return (config("maxfiles") == 0 or config("maxfiles") == nil
    or config("maxfiles") > curvol["count"])
end

--- Close the current volume. It is mandatory to call this function
-- before calling new_vol() again or before calling end_listing().
-- @param output The ls output object, from new_listing()
function end_vol(output)
  local curvol = output.curvol
  local vol = {["volume"] = curvol["name"]}
  local empty = true
  -- "files" is a tab.lua table, so row 1 is the table heading
  if #curvol["files"] ~= 1 then
    vol["files"] = curvol["files"]
    empty = false
  end
  if #curvol["errors"] ~= 0 then
    vol["errors"] = curvol["errors"]
    empty = false
  end
  if #curvol["info"] ~= 0 then
    vol["info"] = curvol["info"]
    empty = false
  end
  if not empty or config("empty") then
    table.insert(output["volumes"], vol)
  end
  output["total"]["files"] = output["total"]["files"] + curvol["count"]
  output["total"]["bytes"] = output["total"]["bytes"] + curvol["bytes"]
  output["curvol"] = nil
end

--- Convert a files table to structured data.
local function files_to_structured(files)
  local result = {}
  local fields = table.remove(files, 1)
  for i=1, #fields do
    fields[i] = string.lower(fields[i])
  end
  for i, file in ipairs(files) do
    result[i] = {}
    for j, value in ipairs(file) do
      result[i][fields[j]] = value
    end
  end
  return result
end

--- Convert a files table to human readable data.
local function files_to_readable(files)
  local outtab = tab.new()
  local fields = files[1]
  local isize
  tab.addrow(outtab, table.unpack(fields))
  for i, field in ipairs(fields) do
    if string.lower(field) == "size" then
      isize = i
      break
    end
  end
  local units = {"K", "M", "G", "T", "P"}
  for i = 2, #files do
    local outfile = {}
    for j, value in ipairs(files[i]) do
      outfile[j] = value
    end
    if config("human") then
      local size = tonumber(outfile[isize])
      -- If tonumber didn't work, it's already in human-readable format
      if size ~= nil then
        local iunit = 0
        while size > 1024 and units[iunit+1] do
          size = size / 1024
          iunit = iunit + 1
        end
        if units[iunit] then
          outfile[isize] = string.format("%.1f %s", size, units[iunit])
        else
          outfile[isize] = tostring(size)
        end
      end
    end
    tab.addrow(outtab, table.unpack(outfile))
  end
  return tab.dump(outtab)
end

--- Close current listing. Return both the structured and the human
--  readable outputs.
-- @param output The ls output object, from new_listing()
-- @return Structured output
-- @return Human readable output
function end_listing(output)
  assert(output["curvol"] == nil)
  local text = {}
  local empty = true
  if #output["info"] == 0 then
    output["info"] = nil
  else
    for _, line in ipairs(output["info"]) do
      text[#text + 1] = line
    end
    empty = false
  end
  if #output["errors"] == 0 then
    output["errors"] = nil
  else
    for _, line in ipairs(output["errors"]) do
      text[#text + 1] = string.format("ERROR: %s", line)
    end
    empty = false
  end
  if #output["volumes"] == 0 then
    output["volumes"] = nil
    output["total"] = nil
  else
    for _, volume in ipairs(output["volumes"]) do
      text[#text + 1] = string.format("Volume %s", volume["volume"])
      if volume["info"] then
        for _, line in ipairs(volume["info"]) do
          text[#text + 1] = string.format("  %s", line)
        end
      end
      if volume["errors"] then
        for _, line in ipairs(volume["errors"]) do
          text[#text + 1] = string.format("  ERROR: %s", line)
        end
      end
      if volume["files"] then
        text[#text + 1] = files_to_readable(volume["files"])
        volume["files"] = files_to_structured(volume["files"])
      end
      text[#text + 1] = ""
    end
    empty = false
  end
  if empty then
    return nil
  else
    return output, table.concat(text, "\n")
  end
end

return _ENV
