-- copyright © 2015 Technicolor

local M = {}

local posix = require("tch.posix")
local open = io.open

local b64alphabet = [[/%.%w]] -- crypt lib uses base64 alphabet `./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`
local hashclass = b64alphabet -- Only specified b64alphabet characters may be present in hash
local saltclass = b64alphabet .. "+=" -- salt may be generated by a client and could additionally contain `+`, '='

--- List all clash users
-- @treturn table Table contains all clash users.
function M.listClashUsers()
  local users = {}
  local fd = open("/etc/passwd", "r")
  if fd then
    for line in fd:lines() do
      local user = line:match("^(.-):.*shell")
      if user and user ~= "root" then
        users[user] = true
      end
    end
    fd:close()
  end
  return users
end

--- Check if specified password is a valid encrypted password, suited for /etc/shadow.
-- @tparam string password A string value containing user's encrypted password.
-- @return true or nil, errmsg
local function validateCryptedPassword(password)
  local err = "Invalid encrypted password: "
  if type(password) ~= "string" then
    return nil, err .. type(password)
  elseif password == "" then
    return nil, err .. "empty"
  end

  -- crypted passwords have form '$<5|6>$<16-char salt>$<43|86-char hash>',
  -- latter two parts encoded in one of the base64 variants - see saltclass, hashclass
  local sha, salt, hash = password:match("^%$([56])%$([" .. saltclass.. "]+)%$([".. hashclass.. "]+)$")

  -- Must be SHA256 or SHA512 format; indicated by `5` or `6` respectively.
  if sha ~= "5" and sha ~= "6" then
    return nil, err .. "not SHA256 or SHA512 format"
  end

  -- Strictly speaking salt is allowed to be shorter than 16 characters;
  -- however be conservative and enforce salts of 16 in length.
  if not salt or #salt ~= 16 then
    return nil, err .. "salt is not 16 characters long"
  end

  -- Of course the hash must be present. By specification, SHA256, SHA512 have hash lengths of 43, 86 characters respectively
  if not hash then
    return nil, err .. "hash is empty"
  elseif sha == "5" and #hash ~= 43 then
    return nil, err .. "SHA256 specified, but hash is not 43 characters long"
  elseif sha == "6" and #hash ~= 86 then
    return nil, err .. "SHA512 specified, but hash is not 86 characters long"
  end

  return true
end

--- Check if specified user has its default shell set to (restricted-)clash in /etc/passw.
-- @tparam string user A string value containing user name.
-- @treturn boolean True If user has clash set as default shell.
-- @return nil If user has other than clash as default shell.
function M.isClashUser(user)
  return M.listClashUsers()[user]
end

--- Check if specified user and password are valid clash credentials.
-- @tparam string user A string value containing user name.
-- @tparam string password A string value containing user's new password.
-- @tparam boolean password_is_crypted A flag indicating that password is already encrypted, or not.
-- @return true or nil, errmsg
function M.validateCredentials(user, password, password_is_crypted)
  if type(user) ~= "string" or user == "" then
    return nil, "Invalid user: " .. type(user)
  end

  if password_is_crypted then
    local valid, msg = validateCryptedPassword(password)
    if not valid then
      return nil, msg
    end
  -- Plaintext password; simply check if it is a string, may even be empty.
  elseif type(password) ~= "string" then
    return nil, "Invalid password: " .. type(password)
  end

  return true
end

--- Helper function to execute the actual password update; 
-- Only valid clash user and password must be passed by the caller.
-- @tparam string user A string value containing user name.
-- @tparam string password A string value containing user's new password.
-- @tparam boolean password_is_crypted A flag indicating that password is already encrypted, or not.
-- @return true or nil, errmsg
local function execUpdate(user, password, password_is_crypted)
  local cmd="/usr/share/transformer/scripts/update_passw.sh '%s' '%s' '%s'"
  local crypted = (password_is_crypted and "-e") or ""
  cmd = cmd:format(user, password, crypted)
  os.execute(cmd)
  -- Here, we can assume password update succeeded.
  return true
end

--- Sets specified user's password to specified password.
-- @tparam string user A string value containing user name.
-- @tparam string password A string value containing user's new password.
-- @return true or nil, errmsg
function M.setPassword(user, password)
  local valid, msg = M.validateCredentials(user, password)
  if not valid then
    return valid, msg
  end

  if not M.isClashUser(user) then
    return nil, "Attempt to set password for no clash user (" .. user .. ")"
  end

  return execUpdate(user, password)
end

--- Sets specified user's password to specified encrypted password.
-- @tparam string user A string value containing user name.
-- @tparam string password A string value containing user's encrypted password, according to format as specified for /etc/shadow.
--   Only SHA256 and SHA512 encrypted passwords will be accepted. See validateCryptedPassword() and
--   http://man7.org/linux/man-pages/man3/crypt.3.html for more specifications.
-- @return true or nil, errmsg
function M.setCryptedPassword(user, password)
  local valid, msg = M.validateCredentials(user, password, true)
  if not valid then
    return valid, msg
  end

  if not M.isClashUser(user) then
    return nil, "Attempt to set encrypted password for no clash user (" .. user .. ")"
  end

  return execUpdate(user, password, true)
end

return M
