-- gitano.admincommand
--
-- Gitano admin-commands
--
-- Copyright 2012-2019 Daniel Silverstone <dsilvers@digital-scurf.org>
-- All rights reserved.
--
-- Redistribution and use in source and binary forms, with or without
-- modification, are permitted provided that the following conditions
-- are met:
-- 1. Redistributions of source code must retain the above copyright
--    notice, this list of conditions and the following disclaimer.
-- 2. Redistributions in binary form must reproduce the above copyright
--    notice, this list of conditions and the following disclaimer in the
--    documentation and/or other materials provided with the distribution.
-- 3. Neither the name of the author nor the names of their contributors
--    may be used to endorse or promote products derived from this software
--    without specific prior written permission.
--
-- THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
-- ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-- IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-- ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
-- FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-- DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
-- OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
-- HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-- LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
-- OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
-- SUCH DAMAGE.
--

local log = require 'gitano.log'
local util = require 'gitano.util'
local repository = require 'gitano.repository'
local config = require 'gitano.config'
local pat = require 'gitano.patterns'
local clod = require 'clod'
local luxio = require 'luxio'
local sio = require 'luxio.simple'
local sp = require 'luxio.subprocess'

-- This is the maximum size (in bytes) of a keyring
local MAX_KEYRING_SIZE = 8 * 1024 * 1024

local function cmdmod()
   return require 'gitano.command'
end

local builtin_as_short = "Become someone else"
local builtin_as_helptext = [[
usage: as <user> <cmdline>...

Runs the given command line as the given user.  The only limitation
is that you are not permitted to run 'as' as someone else.
]]

local function builtin_as_validate(config, _, cmdline)
   -- as
   if #cmdline < 3 then
      log.error("usage: as <user> <cmdline>...")
      return false
   end
   if cmdline[3] == "as" then
      log.error("Cannot use 'as' to run 'as'")
      return false
   end
   -- Strip the cmdline
   local cmdline_copy = util.deep_copy(cmdline)
   table.remove(cmdline_copy,1)
   table.remove(cmdline_copy,1)
   cmdline.copy = cmdline_copy
   -- Attempt to locate the command
   local cmd = cmdmod().get(cmdline[3])
   if not cmd then
      log.error("Unknown command <" .. cmdline[3] .. ">")
      return false
   end
   cmdline.cmd = cmd
   -- If the returned command needs a repo, find it (and save it for later)
   local repo
   if cmd.takes_repo then
      -- Acquire the repository object for the target repo
      repo, cmdline.copy = cmd.detect_repo(config, cmdline.copy)
      if not repo and not cmdline.copy then
         log.error("Unable to continue")
         return false
      end
      cmdline.repo = repo
   end

   -- Finally, validate us
   return cmd.validate(config, cmdline.repo, cmdline.copy)
end

local function builtin_as_prep(conf, _, cmdline, context)
   -- The context contains the user we are right now.
   -- We need to acquire information about that, so ask the config
   local as = { user = context.user }
   cmdline.saved_user = context.user
   config.populate_context(conf, as)
   for k, v in pairs(as) do
      context["as_" .. k] = v
   end
   context.user = cmdline[2]
   context.keytag = "<*>"
   local target_user_name = cmdline[2]
   local target_user_exists = conf.users[cmdline[2]] ~= nil
   if not target_user_exists then
      -- If the target user is not in existence then pretend that
      -- the caller intended to impersonate gitano-bypass.  Since
      -- that user really should exist.  Also we set it so that
      -- they would have to run 'user list' which means that if
      -- we subsequently say the user doesn't exist, it's quite
      -- safe.  Any other case will get a permission denied from
      -- the lace ruleset which should be quite safe.
      cmdline[2] = "gitano-bypass"
      cmdline[3] = "user"
      cmdline[4] = "list"
      while cmdline[5] ~= nil do
         table.remove(cmdline, 5)
      end
   end
   -- Okay, we're now ready to chain through to the called command
   local res, msg = cmdline.cmd.prep(conf, cmdline.repo, cmdline.copy, context)
   if res == "allow" and not target_user_exists then
      -- Normally we wouldn't leak this, but if the user *could* run a command
      -- as gitano-admin
      res, msg = "deny", "User '" .. target_user_name .. "' does not exist."
   end
   return res, msg
end

local function builtin_as_run(conf, _, cmdline, env)
   -- Override some of the environment
   env.GITANO_ORIG_USER = cmdline.saved_user
   env.GITANO_USER = cmdline[2]
   env.GITANO_KEYTAG = "<*>"
   env.GITANO_PROJECT = (cmdline.repo or {}).name
   -- And then simply chain through
   return cmdline.cmd.run(conf, cmdline.repo, cmdline.copy, env)
end

local function update_user_in_htpasswd(conf, userfrom, userto)
   if conf.clod.settings["use_htpasswd"] ~= "yes" then
      return
   end
   local htpasswd_path = os.getenv("HOME") .. "/htpasswd"
   local lock = util.lockfile(htpasswd_path .. ".lock")
   local fh = io.open(htpasswd_path, "r")
   if not fh then return end
   local to_write = {}
   for l in fh:lines() do
      if l:sub(1, #userfrom + 1) == userfrom .. ":" then
         if userto then
            to_write[#to_write + 1] = userto .. ":" .. l:sub(#userfrom + 2, -1)
         end
      else
         to_write[#to_write+1] = l
      end
   end
   fh:close()
   fh = assert(io.open(htpasswd_path .. ".new", "w"))
   fh:write(table.concat(to_write, "\n"))
   fh:write("\n")
   fh:close()
   local ok, errno = luxio.rename(htpasswd_path .. ".new", htpasswd_path)
   if ok ~= 0 then
      log.warn(i18n.expand("ERROR_UNABLE_TO_RENAME_INTO_PLACE",
                           {what="htpasswd", reason=luxio.strerror(errno)}))
   end
   util.unlockfile(lock)
end

local builtin_user_short = "Manage users in Gitano"
local builtin_user_helptext = [[
usage: user [list]
       user add <username> <email> <real name>
       user del <username> [confirm token]
       user email <username> <email>
       user name <username> <real name>
       user rename <username> <newusername> [confirm token]

With no subcommand, or the subcommand 'list' the user command will
show a list of all the users, along with their email addresses and
real names.

With the 'add' subcommand, you can add a new user to the system.
With the 'del' subcommand, you can delete a user from the system.
With the 'email' subcommand, you can change a user's email address.
With the 'name' subcommand, you can change a user's real name.

If you try and delete or rename a user, you will need to paste a
confirmation token which will be supplied if you try and delete or
rename the user without it.  That token is reliant on the state of
the admin repository.  Any admin operations performed between the
two delete or rename attempts will invalidate the token and you will
have to retry.  You may supply '--force' as the token value.  However
you should only do this in automation and cases where you know there
is no chance some other admin operation will have happened in the
meantime which might invalidate the change you want to perform.
]]

local function builtin_user_validate(conf, _, cmdline)
   if #cmdline == 1 then
      cmdline[2] = "list"
   end
   if cmdline[2] ~= "list" and
      cmdline[2] ~= "add" and
      cmdline[2] ~= "del" and
      cmdline[2] ~= "rename" and
      cmdline[2] ~= "email" and
      cmdline[2] ~= "name" then
      log.error("user takes one of list, add, del, rename, email or name")
      return false
   end
   if cmdline[2] == "list" and #cmdline ~= 2 then
      log.error("user list takes no arguments")
      return false
   end
   if #cmdline > 2 then
      cmdline[3] = cmdline[3]:lower()
   end
   if cmdline[2] == "add" and #cmdline < 5 then
      log.error("user add takes a username, email address and real name")
      return false
   end
   if cmdline[2] == "add" and not cmdline[3]:match(pat.VALID_USERNAME) then
      log.error("user name '" .. cmdline[3] .. "' not valid.")
      return false
   end
   if cmdline[2] == "del" and (#cmdline < 3 or #cmdline > 4) then
      log.error("user del takes a username [and a confirmation token]")
      return false
   end
   if cmdline[2] == "email" and #cmdline ~= 4 then
      log.error("user email takes a username and an email address")
      return false
   end
   if cmdline[2] == "name" and #cmdline < 4 then
      log.error("user name takes a username and a real name")
      return false
   end
   if cmdline[2] == "rename" and (#cmdline < 4 or #cmdline > 5) then
      log.error("user rename takes a username, a new username [and a token]")
      return false
   end
   return true
end

local function builtin_user_prep(conf, _, cmdline, context)
   context.operation = "user" .. cmdline[2] -- userlist useradd userdel
   context.targetuser = cmdline[3]
   return conf.repo:run_lace(context)
end

local function builtin_user_run(conf, _, cmdline, env)
   local reason = nil
   if cmdline[2] == "list" then
      -- Listing all the users
      local users = {}
      for k in pairs(conf.users) do
         users[#users+1] = k
      end
      table.sort(users)
      for _, username in ipairs(users) do
         log.stdout(username .. ":" ..
                       conf.users[username].email_address .. ":" ..
                       conf.users[username].real_name)
      end
   elseif cmdline[2] == "add" then
      if conf.users[cmdline[3]] then
         log.fatal("User", cmdline[3], "already exists")
      end
      local new_name = util.deep_copy(cmdline)
      table.remove(new_name, 1)
      table.remove(new_name, 1)
      table.remove(new_name, 1)
      table.remove(new_name, 1)
      new_name = table.concat(new_name, " ")
      local new_clod = clod.parse("")
      new_clod.settings.email_address = cmdline[4]
      new_clod.settings.real_name = new_name
      local utab = {
         clod = new_clod,
         meta = { prefix = "users/" },
         keys = {}
      }
      conf.users[cmdline[3]] = utab
      reason = "Create user " .. cmdline[3]
   elseif cmdline[2] == "name" then
      -- Find the named user
      local utab = conf.users[cmdline[3]]
      if not utab then
         log.fatal("Could not find user:", cmdline[3])
      end
      local new_name = util.deep_copy(cmdline)
      table.remove(new_name, 1)
      table.remove(new_name, 1)
      table.remove(new_name, 1)
      new_name = table.concat(new_name, " ")
      if utab.real_name ~= new_name then
         utab.real_name = new_name
         reason = "Change real name of " .. cmdline[3]
      end
   elseif cmdline[2] == "email" then
      -- Find the named user
      local utab = conf.users[cmdline[3]]
      if not utab then
         log.fatal("Could not find user:", cmdline[3])
      end
      if utab.email_address ~= cmdline[4] then
         utab.email_address = cmdline[4]
         reason = "Change email address of " .. cmdline[3]
      end
   elseif cmdline[2] == "del" then
      local username = cmdline[3]
      local utab = conf.users[username]
      if not utab then
         log.fatal("Could not find user:", username)
      end
      local token = conf.repo:generate_confirmation("delete " .. username)
      if not cmdline[4] then
         log.state("In order to delete", username, "you must supply the following token:")
         log.state(token)
      elseif cmdline[4] ~= token and cmdline[4] ~= '--force' then
         log.error("Tokens do not match.  Did someone else do administrative actions?")
      else
         -- Iterate groups and remove the user from any group it is
         -- a direct member of
         for g, gtab in pairs(conf.groups) do
            if gtab.members[username] then
               table.remove(gtab.members, gtab.members[username])
               gtab.members[username] = nil
               gtab.changed_tables()
               log.state("Removed", username, "from membership of", g)
            end
         end
         -- Now remove the user
         conf.users[username] = nil
         -- And explain what
         reason = "Delete user " .. username
      end
   elseif cmdline[2] == "rename" then
      local oldusername = cmdline[3]
      local newusername = cmdline[4]
      local utab = conf.users[oldusername]
      if not utab then
         log.fatal("Could not find user:", oldusername)
      end
      if conf.users[newusername] then
         log.fatal("New username already exists:", newusername)
      end
      local token = conf.repo:generate_confirmation("rename " .. oldusername ..
                                                       " to " .. newusername)
      if not cmdline[5] then
         log.state("In order to rename", oldusername, "you must supply the following token:")
         log.state(token)
      elseif cmdline[5] ~= token and cmdline[5] ~= '--force' then
         log.error("Tokens do not match.  Did someone else do administrative actions?")
      else
         -- Iterate groups and rename the user in any group it is
         -- a direct member of
         for g, gtab in pairs(conf.groups) do
            if gtab.members[oldusername] then
               gtab.members[gtab.members[oldusername]] = newusername
               gtab.members[newusername] = gtab.members[oldusername]
               gtab.members[oldusername] = nil
               gtab.changed_tables()
               log.state("Renamed:", oldusername, "to", newusername,
                         "in membership of group:", g)
            end
         end
         -- Now rename the user itself
         conf.users[newusername] = utab
         conf.users[oldusername] = nil
         -- And explain what
         reason = "Rename user " .. oldusername .. " to " .. newusername
         -- Finally update the environment if necessary
         if env.GITANO_USER == oldusername then
            env.GITANO_USER = newusername
         end
         if env.GITANO_ORIG_USER == oldusername then
            env.GITANO_ORIG_USER = newusername
         end
      end
   end
   if reason then
      -- Need to try and make a config commit
      local ok, commit = config.commit(conf, reason, env.GITANO_USER, env.GITANO_ORIG_USER)
      if not ok then
         log.fatal(commit)
      end
      log.state("Committed: " .. reason)
      if cmdline[2] == "rename" then
         update_user_in_htpasswd(conf, cmdline[3], cmdline[4])
         local function reown_repo(_, repo)
            if repo:conf_get("project.owner") == cmdline[3] then
               local ok, msg = repo:conf_set_and_save(
                  "project.owner", cmdline[4],
                  env.GITANO_USER, env.GITANO_ORIG_USER)
               if not ok then
                  log.error(msg)
                  return "exit", 1
               end
            end
         end
         repository.foreach(conf, reown_repo)
      elseif cmdline[2] == "del" then
         update_user_in_htpasswd(conf, cmdline[3], nil)
      end
   end
   return "exit", 0
end

local builtin_group_short = "Manage groups in Gitano"
local builtin_group_helptext = [[
usage: group [list]
       group show <groupname>
       group add <groupname> <description>
       group del <groupname [confirm token]
       group rename <groupname> <newgroupname> [confirm token]
       group description <groupname> <description>
       group adduser <groupname> <username>
       group deluser <groupname> <username> [confirm token]
       group addgroup <groupname> <groupname>
       group delgroup <groupname> <groupname> [confirm token]

With no subcommand, or the subcommand 'list' the user command will
show a list of all the users, along with their descriptions

Showing a group will display membership information

Adding a user to a group adds the user to the direct membership list.
Removing a user from a group removes them from the direct membership
list only.

If you add a group to a group, you are stating that everyone in the
sub group is to be considered a member of this group also.  Removing a
group undoes this effect.

To delete or rename a group, remove a user from a group or remove a
group from a group requires a confirmation token which will be
supplied to you if missing.  You may supply '--force' as the token value.
However you should only do this in automation and cases where you know there
is no chance some other admin operation will have happened in the
meantime which might invalidate the change you want to perform.
]]

local function builtin_group_validate(conf, _, cmdline)
   if not cmdline[2] then
      cmdline[2] = "list"
   end
   local groupsubs = util.set {"list", "show", "add", "del", "rename",
                               "adduser", "deluser", "addgroup", "delgroup", "description"}
   if not groupsubs[cmdline[2]] then
      log.error("Unknown sub command", cmdline[2], "for group")
      return false
   end
   if #cmdline > 2 then
      cmdline[3] = cmdline[3]:lower()
   end
   if cmdline[2] == "list" and #cmdline ~= 2 then
      log.error("List takes no arguments")
      return false
   end
   if cmdline[2] == "show" and #cmdline ~= 3 then
      log.error("Show takes a group name")
      return false
   end
   if cmdline[2] == "add" and #cmdline < 4 then
      log.error("Add takes a group name and a description")
      return false
   end
   if cmdline[2] == "add" and not cmdline[3]:match(pat.VALID_GROUPNAME) then
      log.error("group name '" .. cmdline[3] .. "' not valid.")
      return false
   end
   if cmdline[2] == "del" and (#cmdline < 3 or #cmdline > 4) then
      log.error("Del takes a group name and a confirmation token")
      return false
   end
   if cmdline[2] == "rename" and (#cmdline < 4 or #cmdline > 5) then
      log.error("Rename takes a group name, a new group name [and a token]")
      return false
   end
   if cmdline[2] == "description" and #cmdline < 4 then
      log.error("Description takes a group name and a description")
      return false
   end
   if cmdline[2] == "adduser" and #cmdline ~= 4 then
      log.error("Adduser takes a group name and a username")
      return false
   end
   if cmdline[2] == "deluser" and (#cmdline < 4 or #cmdline > 5) then
      log.error("Deluser takes a group name, a username and a confirmation token")
      return false
   end
   if cmdline[2] == "addgroup" and #cmdline ~= 4 then
      log.error("Addgroup takes a group name and a second group name")
      return false
   end
   if cmdline[2] == "delgroup" and (#cmdline < 4 or #cmdline > 5) then
      log.error("Delgroup takes a group name, a second group name, and a confirmation token")
      return false
   end
   if ({adduser=true,addgroup=true,deluser=true,delgroup=true})[cmdline[2]] then
      cmdline[4] = cmdline[4]:lower()
   end
   return true
end

local function builtin_group_prep(conf, _, cmdline, context)
   context.operation = "group" .. cmdline[2]
   util.add_splitable(context, "targetgroup", cmdline[3],
                      "-", "prefix", "suffix")
   if cmdline[2]:match("user") or cmdline[2]:match("group") then
      util.add_splitable(context, "member", cmdline[4],
                         "-", "prefix", "suffix")
   end
   return conf.repo:run_lace(context)
end

local function builtin_group_run(conf, _, cmdline, env)
   local reason = nil
   if cmdline[2] == "list" then
      local groups = {}
      for g in pairs(conf.groups) do
         groups[#groups+1] = g
      end
      table.sort(groups)
      for _, g in ipairs(groups) do
         log.stdout(g .. ":" .. conf.groups[g].settings.description)
      end
   elseif cmdline[2] == "show" then
      local g, gtab = cmdline[3], conf.groups[cmdline[3]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      log.stdout(g .. ":" .. gtab.settings.description)
      for i, m in ipairs(gtab.members) do
         log.stdout(" => " .. m)
      end
      for i, gg in ipairs(gtab.subgroups) do
         log.stdout(" [] " .. gg)
      end
   elseif cmdline[2] == "add" then
      local g = cmdline[3]
      if conf.groups[g] then
         log.fatal("Group", g, "already exists")
      end
      local new_desc = util.deep_copy(cmdline)
      table.remove(new_desc, 1)
      table.remove(new_desc, 1)
      table.remove(new_desc, 1)
      new_desc = table.concat(new_desc, " ")
      local new_clod = clod.parse("")
      new_clod.settings.description = new_desc
      local gtab = {
         clod = new_clod,
         members = {},
         subgroups = {},
         meta = { prefix = "groups/" }
      }
      conf.groups[g] = gtab
      reason = "Create group " .. g
   elseif cmdline[2] == "description" then
      local g, gtab = cmdline[3], conf.groups[cmdline[3]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      local new_desc = util.deep_copy(cmdline)
      table.remove(new_desc, 1)
      table.remove(new_desc, 1)
      table.remove(new_desc, 1)
      new_desc = table.concat(new_desc, " ")
      if gtab.settings.description ~= new_desc then
         gtab.settings.description = new_desc
         reason = "Change group description of " .. g
      end
   elseif cmdline[2] == "del" then
      local g = cmdline[3]
      if not conf.groups[g] then
         log.fatal("Unknown group", g)
      end
      local token = conf.repo:generate_confirmation("delete group " .. g)
      if not cmdline[4] then
         log.state("In order to delete group", g, "you must supply the following token:")
         log.state(token)
      elseif cmdline[4] ~= token and cmdline[4] ~= '--force' then
         log.fatal("Token does not match.  Has someone else done administrative actions?")
      else
         for gg, gtab in pairs(conf.groups) do
            if gtab.subgroups[g] then
               table.remove(gtab.subgroups, gtab.subgroups[g])
               gtab.subgroups[g] = nil
               gtab.changed_tables()
               log.state("Removed:", g, "from subgroup membership of", gg)
            end
         end
         conf.groups[g] = nil
         reason = "Delete group " .. g
      end
   elseif cmdline[2] == "rename" then
      local g = cmdline[3]
      if not conf.groups[g] then
         log.fatal("Unknown group", g)
      end
      local newg = cmdline[4]
      if conf.groups[newg] then
         log.fatal("New group", newg, "already exists.")
      end
      local token = conf.repo:generate_confirmation("rename group " .. g ..
                                                       " to " .. newg)
      if not cmdline[5] then
         log.state("In order to rename group", g, "to", newg,
                   "you must supply the following token:")
         log.state(token)
      elseif cmdline[5] ~= token and cmdline[5] ~= '--force' then
         log.fatal("Token does not match.  Has someone else done administrative actions?")
      else
         for gg, gtab in pairs(conf.groups) do
            if gtab.subgroups[g] then
               gtab.subgroups[gtab.subgroups[g]] = newg
               gtab.subgroups[newg] = gtab.subgroups[g]
               gtab.subgroups[g] = nil
               gtab.changed_tables()
               log.state("Renamed:", g, "to", newg,
                         "in subgroup membership of", gg)
            end
         end
         conf.groups[newg] = conf.groups[g]
         conf.groups[g] = nil
         reason = "Rename group " .. g .. " to " .. newg
      end
   elseif cmdline[2] == "adduser" then
      local g, gtab, u, utab =
         cmdline[3], conf.groups[cmdline[3]],
      cmdline[4], conf.users[cmdline[4]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      if not utab then
         log.fatal("Unknown user", u)
      end
      if not gtab.members[u] then
         gtab.members[#gtab.members+1] = u
         gtab.members[u] = #gtab.members
         gtab.changed_tables()
         reason = "Add " .. u .. " to " .. g
      else
         log.state("User", u, "already a member of", g)
      end
   elseif cmdline[2] == "deluser" then
      local g, gtab, u, utab =
         cmdline[3], conf.groups[cmdline[3]],
      cmdline[4], conf.users[cmdline[4]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      if not utab then
         log.fatal("Unknown user", u)
      end
      if not gtab.members[u] then
         log.fatal("User", u, "is not a member of", g)
      end
      local token = conf.repo:generate_confirmation("delete user " .. u ..
                                                       " from group " .. g)
      if not cmdline[5] then
         log.state("To delete user", u, "from group", g, "you will need this token:")
         log.state(token)
      elseif cmdline[5] ~= token and cmdline[5] ~= '--force' then
         log.fatal("Token does not match.  Did someone else do administrative actions?")
      else
         table.remove(gtab.members, gtab.members[u])
         gtab.members[u] = nil
         gtab.changed_tables()
         reason = "Remove " .. u .. " from " .. g
      end
   elseif cmdline[2] == "addgroup" then
      local g, gtab, g2, g2tab =
         cmdline[3], conf.groups[cmdline[3]],
      cmdline[4], conf.groups[cmdline[4]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      if not g2tab then
         log.fatal("Unknown group", g2)
      end
      if not gtab.subgroups[g2] then
         gtab.subgroups[#gtab.subgroups+1] = g2
         gtab.subgroups[g2] = #gtab.subgroups
         gtab.changed_tables()
         reason = "Add group " .. g2 .. " to " .. g
      else
         log.state("Group", g2, "already a subgroup of", g)
      end
   elseif cmdline[2] == "delgroup" then
      local g, gtab, g2, g2tab =
         cmdline[3], conf.groups[cmdline[3]],
      cmdline[4], conf.groups[cmdline[4]]
      if not gtab then
         log.fatal("Unknown group", g)
      end
      if not g2tab then
         log.fatal("Unknown group", g2)
      end
      if not gtab.subgroups[g2] then
         log.fatal("Group", g2, "is not a subgroup of", g)
      end
      local token = conf.repo:generate_confirmation("delete group " .. g ..
                                                       "from group " .. g2)
      if not cmdline[5] then
         log.state("To delete group", g2, "from group", g, "you will need this token:")
         log.state(token)
      elseif cmdline[5] ~= token and cmdline[5] ~= '--force' then
         log.fatal("Token does not match.  Did someone else do administrative actions?")
      else
         table.remove(gtab.subgroups, gtab.subgroups[g2])
         gtab.members[g2] = nil
         gtab.changed_tables()
         reason = "Remove group " .. g2 .. " from " .. g
      end
   else
      log.fatal("Unknown sub command", cmdline[2])
   end
   if reason then
      local ok, commit = config.commit(conf, reason, env.GITANO_USER, env.GITANO_ORIG_USER)
      if not ok then
         log.fatal(commit)
      end
      log.state("Committed: " .. reason)
   end
   return "exit", 0
end

local builtin_keyring_short = "Manage keyrings"
local builtin_keyring_helptext = [[
usage: keyring [list]
       keyring show <keyring>
       keyring create <keyring>
       keyring destroy <keyring> [<token>]
       keyring import <keyring>
       keyring export <keyring> [<fingerprint>...]
       keyring delkey <keyring> <fingerprint> [<token>]

With no subcommand, or the subcommand 'list', the keyring command will list
all the keyrings to which the calling user has any access.

With the 'show' subcommand, the keys in the named keyring will be listed in
the form of their fingerprints.

With the 'create' subcommand, a new keyring will be created.

With the 'destroy' subcommand, a keyring will be removed from the server.

With the 'import' subcommand, a keyring can have keys added or updated by
piping the key(s) in.

With the 'export' subcommand, a keyring will be exported to stdout.  It will
be ASCII armored for safety.  You may optionally state the fingerprints of the
keys you wish to export from the keyring.

With the 'delkey' subcommand, a keyring can have a key removed, by fingerprint.
]]

local function builtin_keyring_validate(conf, _, cmdline)
   if #cmdline == 1 then
      cmdline[2] = "list"
   end
   if cmdline[2] == "list" then
      if #cmdline == 2 then
         return true
      end
      log.error("usage: keyring list")
   end
   if cmdline[2] == "create" then
      if #cmdline == 3 then
         return true
      end
      log.error("usage: keyring create <keyring>")
   end
   if cmdline[2] == "destroy" then
      if #cmdline == 3 or #cmdline == 4 then
         return true
      end
      log.error("usage: keyring destroy <keyring> [<token>]")
   end
   if cmdline[2] == "show" then
      if #cmdline == 3 then
         return true
      end
      log.error("usage: keyring show <keyring>")
   end
   if cmdline[2] == "import" then
      if #cmdline == 3 then
         return true
      end
      log.error("usage: keyring import <keyring>")
   end
   if cmdline[2] == "export" then
      if #cmdline == 3 then
         return true
      end
      if #cmdline > 3 then
         local ok = true
         for i = 4, #cmdline do
            if not cmdline[i]:match(pat.VALID_KEY_FINGERPINRT) then
               log.error("error: '" .. cmdline[i] .. "' is not a valid fingerprint")
               ok = false
            end
         end
         return ok
      end
      log.error("usage: keyring export <keyring> [<fingerprint>...]")
   end
   if cmdline[2] == "delkey" then
      if #cmdline == 4 or #cmdline == 5 then
         if not cmdline[4]:match(pat.VALID_KEY_FINGERPRINT) then
            log.error("error: '" .. cmdline[i] .. "' is not a valid fingerprint")
            return false
         end
         return true
      end
      log.error("usage: keyring delkey <keyring> <fingerprint> [<token>]")
   end
   log.error("Unable to parse keyring commandline properly")
   return false
end

local function builtin_keyring_prep(conf, _, cmdline, context)
   context.operation = "keyring" .. cmdline[2]
   if cmdline[2] == "list" then
      -- for list, we don't do a check because we'll do it later.
      cmdline._stolen_context = context
      return "allow", "Always allow list"
   end
   -- Not list...
   context.keyringname = cmdline[3]
   return conf.repo:run_lace(context)
end

local function builtin_keyring_run(conf, _, cmdline, env)
   if cmdline[2] == "list" then
      local keyrings = {}
      for k, ktab in pairs(conf.keyrings) do
         local ctx = util.deep_copy(cmdline._stolen_context)
         ctx.keyringname = k
         local result = conf.repo:run_lace(ctx)
         if result == "allow" then keyrings[#keyrings+1] = k end
      end
      table.sort(keyrings)
      for _, k in ipairs(keyrings) do
         log.stdout(k)
      end
   end
   local reason = nil
   if cmdline[2] == "create" then
      local keyringname = cmdline[3]
      if conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " already exists")
         return "exit", 1
      end
      if not keyringname:match(pat.VALID_KEYRING_NAME) then
         log.error("Keyring " .. keyringname .. " is not lower-alphanumeric")
         return "exit", 1
      end
      local emptysha = conf.repo.git:hash_object("blob", "", true)
      conf.keyrings[keyringname] = {
         meta = { prefix = "keyrings/" },
         blob = conf.repo.git:get(emptysha)
      }
      reason = "create keyring " .. keyringname
   end
   if cmdline[2] == "destroy" then
      local keyringname = cmdline[3]
      if not conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " does not exist")
         return "exit", 1
      end
      local token = conf.repo:generate_confirmation("keyring destroy " .. keyringname)
      if not cmdline[4] then
         log.state("In order to destroy", keyringname, "you must supply the following token:")
         log.state(token)
      elseif cmdline[4] ~= token and cmdline[4] ~= '--force' then
         log.error("Tokens do not match.  Did someone else do administrative actions?")
         return "exit", 1
      else
         conf.keyrings[keyringname] = nil
         reason = "destroy keyring " .. keyringname
      end
   end
   if cmdline[2] == "show" then
      local keyringname = cmdline[3]
      if not conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " does not exist")
         return "exit", 1
      end
      local fd, filename = util.tempfile(conf.repo)
      local sfd = sio.wrap_fd(fd, false, filename)
      local b = conf.keyrings[keyringname].blob.raw
      local w = 0
      local _b, err
      while w < #b do
         _b, err = sfd:write(b, w)
         if _b == nil or _b < 1 then
            -- I know we're not handling EINTR/EAGAIN, I don't care
            w = -1
            break
         end
         w = w + _b
      end
      sfd:close()
      if w == -1 then
         -- Failure to write keyring out...
         luxio.unlink(filename)
         log.fatal("Unable to write keyring out: " .. err)
      end
      -- Keyring was written out, now we need to get GPG to add stdin to it...
      local null = luxio.open("/dev/null", luxio.O_RDWR)
      local proc = sp.spawn_simple({
            "gpg", "--no-default-keyring", "--keyring", filename,
            "--primary-keyring", filename, "--with-colons", "--fingerprint",
            "--list-keys",
            stdout = sp.PIPE,
            stderr = null,
      })
      luxio.close(null)
      local alloutput = proc.stdout:read("*a")
      proc.stdout:close()
      local _, code = proc:wait()
      luxio.unlink(filename)
      if code ~= 0 then
         log.fatal("Unable to list keyring: GPG returned " .. tostring(code))
      end
      for fingerprint in alloutput:gmatch(pat.GPG_OUTPUT_FINGERPRINT_MATCH) do
         log.stdout(fingerprint)
      end
   end
   if cmdline[2] == "import" then
      local keyringname = cmdline[3]
      if not conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " does not exist")
         return "exit", 1
      end
      local fd, filename = util.tempfile(conf.repo)
      local sfd = sio.wrap_fd(fd, false, filename)
      local b = conf.keyrings[keyringname].blob.raw
      local w = 0
      local _b, err
      while w < #b do
         _b, err = sfd:write(b, w)
         if _b == nil or _b < 1 then
            -- I know we're not handling EINTR/EAGAIN, I don't care
            w = -1
            break
         end
         w = w + _b
      end
      sfd:close()
      if w == -1 then
         -- Failure to write keyring out...
         luxio.unlink(filename)
         log.fatal("Unable to write keyring out: " .. err)
      end
      -- Keyring was written out, now we need to get GPG to add stdin to it...
      local null = luxio.open("/dev/null", luxio.O_RDWR)
      local proc = sp.spawn_simple({
            "gpg", "--no-default-keyring", "--keyring", filename,
            "--primary-keyring", filename, "--import",
            stdout = null,
            stderr = null,
      })
      luxio.close(null)
      local _, code = proc:wait()
      if code ~= 0 then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring: GPG returned " .. tostring(code))
      end
      -- Keyring was updated, need to reload it...
      local ret, sdat = luxio.lstat(filename)
      if ret ~= 0 then
         luxio.unlink(filename)
         log.fatal("Unable to stat keyring")
      end
      if sdat.size > MAX_KEYRING_SIZE then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring, size " .. tostring(sdat.size) ..
                      " is more than maximum size of " .. tostring(MAX_KEYRING_SIZE))
      end
      -- Load the keyring into an object
      sfd, err = sio.open(filename, "r")
      if not sfd then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring, cannot reload it: " .. err)
      end
      b = sfd:read("*a")
      sfd:close()
      luxio.unlink(filename)
      -- Feed the object to the config repo...
      local sha = conf.repo.git:hash_object("blob", b, true)
      conf.keyrings[keyringname].blob = conf.repo.git:get(sha)
      reason = "update keyring " .. keyringname
   end
   if cmdline[2] == "export" then
      local keyringname = cmdline[3]
      if not conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " does not exist")
         return "exit", 1
      end
      local fd, filename = util.tempfile(conf.repo)
      local sfd = sio.wrap_fd(fd, false, filename)
      local b = conf.keyrings[keyringname].blob.raw
      local w = 0
      local _b, err
      while w < #b do
         _b, err = sfd:write(b, w)
         if _b == nil or _b < 1 then
            -- I know we're not handling EINTR/EAGAIN, I don't care
            w = -1
            break
         end
         w = w + _b
      end
      sfd:close()
      if w == -1 then
         -- Failure to write keyring out...
         luxio.unlink(filename)
         log.fatal("Unable to write keyring out: " .. err)
      end
      -- Keyring was written out, now we need to get GPG to add stdin to it...
      local null = luxio.open("/dev/null", luxio.O_RDWR)
      local args = {
         "gpg", "--no-default-keyring", "--keyring", filename,
         "--primary-keyring", filename, "--armor", "--export",
         stderr = null,
      }
      for i = 4, #cmdline do
         args[#args+1] = cmdline[i]
      end
      local proc = sp.spawn_simple(args)
      luxio.close(null)
      local _, code = proc:wait()
      luxio.unlink(filename)
      if code ~= 0 then
         log.fatal("Unable to export keys: GPG returned " .. tostring(code))
      end
   end
   if cmdline[2] == "delkey" then
      local keyringname = cmdline[3]
      local fingerprint = cmdline[4]
      local token = conf.repo:generate_confirmation("delkey " .. keyringname .. fingerprint)
      if not conf.keyrings[keyringname] then
         log.error("Keyring " .. keyringname .. " does not exist")
         return "exit", 1
      end
      if not cmdline[5] then
         log.state("In order to remove", fingerprint, "from", keyringname,
                   "you must supply the following token:")
         log.state(token)
         return "exit", 0
      elseif cmdline[5] ~= token and cmdline[5] ~= '--force' then
         log.error("Tokens do not match.  Did someone else do administrative actions?")
         return "exit", 1
      end
      local fd, filename = util.tempfile(conf.repo)
      local sfd = sio.wrap_fd(fd, false, filename)
      local b = conf.keyrings[keyringname].blob.raw
      local w = 0
      local _b, err
      while w < #b do
         _b, err = sfd:write(b, w)
         if _b == nil or _b < 1 then
            -- I know we're not handling EINTR/EAGAIN, I don't care
            w = -1
            break
         end
         w = w + _b
      end
      sfd:close()
      if w == -1 then
         -- Failure to write keyring out...
         luxio.unlink(filename)
         log.fatal("Unable to write keyring out: " .. err)
      end
      -- Keyring was written out, now we need to get GPG to add stdin to it...
      local null = luxio.open("/dev/null", luxio.O_RDWR)
      local proc = sp.spawn_simple({
            "gpg", "--no-default-keyring", "--keyring", filename,
            "--primary-keyring", filename, "--batch", "--delete-key", fingerprint,
            stdout = null,
            stderr = null,
      })
      luxio.close(null)
      local _, code = proc:wait()
      if code ~= 0 then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring: GPG returned " .. tostring(code))
      end
      -- Keyring was updated, need to reload it...
      local ret, sdat = luxio.lstat(filename)
      if ret ~= 0 then
         luxio.unlink(filename)
         log.fatal("Unable to stat keyring")
      end
      if sdat.size > MAX_KEYRING_SIZE then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring, size " .. tostring(sdat.size) ..
                      " is more than maximum size of " .. tostring(MAX_KEYRING_SIZE))
      end
      -- Load the keyring into an object
      sfd, err = sio.open(filename, "r")
      if not sfd then
         luxio.unlink(filename)
         log.fatal("Unable to update keyring, cannot reload it: " .. err)
      end
      b = sfd:read("*a")
      sfd:close()
      luxio.unlink(filename)
      -- Feed the object to the config repo...
      local sha = conf.repo.git:hash_object("blob", b, true)
      conf.keyrings[keyringname].blob = conf.repo.git:get(sha)
      reason = "remove " .. fingerprint .. " from keyring " .. keyringname
   end
   if reason then
      -- Need to try and make a config commit
      local ok, commit = config.commit(conf, reason, env.GITANO_USER, env.GITANO_ORIG_USER)
      if not ok then
         log.fatal(commit)
      end
      log.state("Committed: " .. reason)
   end
   return "exit", 0
end

local builtin_graveyard_short = "Manage the graveyard"
local builtin_graveyard_helptext = [[
usage: graveyard [list]
       graveyard restore <entry> <name>
       graveyard purge [<entry>]

With no subcommand, or the subcommand 'list', the graveyard command will list
all the entries in the graveyard.

With the restore subcommand, a graveyard entry will be restored to the location
provided.  The caller will need create permissions in the given location, as
well as the rights to read the graveyard.

With the purge subcommand, a graveyard entry will be purged entirely.  Note
that once a graveyard entry is purged, it cannot be restored except if you have
made an out-of-band backup.
]]

local function builtin_graveyard_validate(conf, _, cmdline)
   if #cmdline == 1 then
      cmdline[2] = "list"
   end
   if cmdline[2] == "list" then
      if #cmdline == 2 then
         return true
      end
      log.error("usage: graveyard list")
   end
   if cmdline[2] == "restore" then
      if #cmdline == 4 then
         cmdline[3] = cmdline[3]:gsub("/", "")
         return true
      end
      log.error("usage: graveyard restore <entry> <name>")
   end
   if cmdline[2] == "purge" then
      if #cmdline < 4 then
         if cmdline[3] then
            cmdline[3] = cmdline[3]:gsub("/", "")
         end
         return true
      end
      log.error("usage: graveyard purge [<entry>]")
   end
   log.error("Unable to parse graveyard commandline properly")
   return false
end
local function builtin_graveyard_prep(conf, _, cmdline, context)
   local context_copy = util.deep_copy(context)
   context.operation = "graveyard" .. cmdline[2]
   if cmdline[2] == "restore" then
      context.target = cmdline[4]
   end
   if cmdline[2] == "purge" then
      context.target = cmdline[3] or "all"
   end
   local action, reason = conf.repo:run_lace(context)
   if action == "allow" and cmdline[2] == "restore" then
      context_copy.operation = "createrepo"
      local repo = repository.find(conf, cmdline[4])
      return repo:run_lace(context_copy)
   end
   return action, reason
end

local function builtin_graveyard_run(conf, _, cmdline, env)
   local graveyard_base = config.repo_path() .. "/.graveyard/"
   if cmdline[2] == "list" then
      -- List the contents of the graveyard.
      local dirp, err = luxio.opendir(graveyard_base)
      if not dirp then
         if err == luxio.ENOTDIR then
            log.error("Graveyard is not present, or empty")
            return "exit", 1
         end
         log.error("Error opening graveyard: " .. luxio.strerror(err))
         return "exit", 1
      end
      local e, i
      repeat
         e, i = luxio.readdir(dirp)
         if e == 0 then
            if not i.d_name:find(pat.DOTFILE) then
               log.stdout(i.d_name)
            end
         end
      until not e
      dirp = nil  -- Allow GC of DIR handle
   elseif cmdline[2] == "restore" then
      -- Restoring a repository needs the prefix dirs to be made
      local temp_repo, msg = repository.find(conf, cmdline[4])
      if not temp_repo then
         log.error("Unable to proceed: " .. msg)
         return "exit", 1
      end
      local repo_path = temp_repo:fs_path()
      local restore_src = graveyard_base .. cmdline[3]
      -- Prepare the path towards the repo...
      local ok, msg = util.mkdir_p(util.dirname(repo_path))
      if not ok then
         log.error("Unable to proceed: " .. msg)
         return "exit", 1
      end
      -- Restore the repository
      log.info("Trying to restore", cmdline[3], "as", cmdline[4])
      log.ddebug(restore_src, "=>", repo_path)
      local e, errno = luxio.rename(restore_src, repo_path)
      if e ~= 0 then
         log.error("Restore failed.  Could not rename: " .. luxio.strerror(errno))
         return "exit", 1
      end
      -- Re-find the repository
      local repo, msg = repository.find(conf, cmdline[4])
      if not repo then
         log.error("Restore failed.  Could not find restored repo: " .. msg)
         return "exit", 1
      end
   elseif cmdline[2] == "purge" then
      local match = cmdline[3]
      local to_remove = {}
      local dirp, err = luxio.opendir(graveyard_base)
      if not dirp then
         if err == luxio.ENOTDIR then
            log.error("Graveyard is not present, or empty")
            return "exit", 1
         end
         log.error("Error opening graveyard: " .. luxio.strerror(err))
         return "exit", 1
      end
      local e, i
      repeat
         e, i = luxio.readdir(dirp)
         if e == 0 then
            if not i.d_name:find(pat.DOTFILE) then
               if not match or (match == i.d_name) then
                  to_remove[#to_remove+1] = i.d_name
               end
            end
         end
      until not e
      dirp = nil  -- Allow GC of DIR handle
      if #to_remove == 0 then
         if match then
            log.error("Unable to find", match, "to remove")
         else
            log.error("Nothing in the graveyard to purge")
         end
         return "exit", 1
      end
      for i = 1, #to_remove do
         log.state("Purging", to_remove[i], "from graveyard...")
         local ok, msg = util.rm_rf(graveyard_base .. to_remove[i])
         if not ok then
            log.error("Unable to remove:", msg)
         end
      end
   end
   return "exit", 0
end

local function register_commands(reg)
   assert(reg("as", builtin_as_short, builtin_as_helptext,
              builtin_as_validate, builtin_as_prep, builtin_as_run,
              false, false, true))

   assert(reg("user", builtin_user_short, builtin_user_helptext,
              builtin_user_validate, builtin_user_prep,
              builtin_user_run, false, false, true))

   assert(reg("group", builtin_group_short, builtin_group_helptext,
              builtin_group_validate, builtin_group_prep,
              builtin_group_run, false, false, true))

   assert(reg("keyring", builtin_keyring_short, builtin_keyring_helptext,
              builtin_keyring_validate, builtin_keyring_prep,
              builtin_keyring_run, false, false, true))

   assert(reg("graveyard", builtin_graveyard_short, builtin_graveyard_helptext,
              builtin_graveyard_validate, builtin_graveyard_prep,
              builtin_graveyard_run, false, false, true))

end

return {
   register = register_commands
}
