-- gitano.command -- -- Gitano command processing -- -- Copyright 2012-2019 Daniel Silverstone -- 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 pattern = require 'gitano.patterns' local sio = require "luxio.simple" local cmds = {} local function default_detect_repo(config, parsed_cmdline) local repo, msg if #parsed_cmdline > 1 then -- Acquire the repository object for the target repo from arg 2 repo, msg = repository.find(config, parsed_cmdline[2]) if not repo then log.critical("Unable to locate repository.") log.critical(" * " .. (tostring(msg))) return nil, nil end if repo.is_nascent then log.info("Repository " .. repo.name .. " is nascent") end else log.critical("No repository provided.") return nil, nil end return repo, parsed_cmdline end local function register_cmd(cmdname, short, helptext, validate_fn, prep_fn, run_fn, takes_repo, hidden, is_admin, detect_repo, suppress_error_msgs) --[[ log.ddebug("Register command", cmdname) if takes_repo then log.ddebug(" => Takes a repo") end --]] if cmds[cmdname] then log.warn("Attempt to double-register", cmdname) return false, "Attempt to double-register " .. cmdname end cmds[cmdname] = { name = cmdname, validate = validate_fn, prep = prep_fn, run = run_fn, takes_repo = takes_repo, hidden = hidden, admin = is_admin, short = short, helptext = helptext, suppress_error_msgs = suppress_error_msgs, detect_repo = detect_repo or default_detect_repo } cmds[#cmds+1] = cmdname table.sort(cmds) return true end local function get_cmd(cmdname) local cmd = cmds[cmdname] if not cmd then return nil, "Unknown command " .. cmdname end return { validate = cmd.validate, prep = cmd.prep, run = cmd.run, takes_repo = cmd.takes_repo, detect_repo = cmd.detect_repo, suppress_error_msgs = cmd.suppress_error_msgs, name = cmd.name, } end local builtin_version_short = "Get the Gitano version" local builtin_version_helptext = [[ usage: version [machine] Without an argument, display the version and basic copyright information for Gitano in a form which a human can parse. With the 'machine' command, display the Gitano version as a machine-parseable list of values instead. **NOTE**: the format of the machine-parseable output is not guaranteed stable at this time. ]] local function builtin_version_validate(config, _, cmdline) if #cmdline > 2 then log.error("usage: version [machine]") return false end if #cmdline == 2 and cmdline[2] ~= "machine" then log.error("usage: version [machine]") return false end return true end local function builtin_version_prep(config, _, cmdline, context) return "allow", "Always allowed to ask for the version" end local function builtin_version_run(config, _, cmdline, env) -- We do this lazily because otherwise circular includes get in our way local _gitano = require "gitano" if #cmdline == 1 then log.state(_gitano.VERSION_STRING) log.state(_gitano.COPYRIGHT) else -- Machine-parseable version log.state(("VERSION:%d.%d.%d"):format( _gitano.VERSION.major, _gitano.VERSION.minor, _gitano.VERSION.patch)) log.state(("MAJOR:%d"):format(_gitano.VERSION.major)) log.state(("MINOR:%d"):format(_gitano.VERSION.minor)) log.state(("PATCH:%d"):format(_gitano.VERSION.patch)) end return "exit", 0 end assert(register_cmd("version", builtin_version_short, builtin_version_helptext, builtin_version_validate, builtin_version_prep, builtin_version_run, false, false)) local builtin_help_short = "Ask for help" local builtin_help_helptext = [[ usage: help [admin|command] Without the command argument, lists all visible commands. With the command argument, provides detailed help about the given command. If the command argument is specifically 'admin' then list the admin commands instead of the normal commands. If it is 'all' then list all the commands, even the hidden commands. ]] local function builtin_help_validate(config, repo, cmdline) if #cmdline > 2 then log.error("usage: help [admin|command]") return false end if #cmdline == 2 then if cmdline[2] == "all" or cmdline[2] == "admin" then return true end if not cmds[cmdline[2]] then log.error("Unknown command:", help) return false end end return true end local function builtin_help_prep(config, repo, cmdline, context) return "allow", "Always allowed to ask for help" end local function builtin_help_run(config, repo, cmdline, env) local function do_want(cmd) if cmdline[2] == "all" then return true end if cmdline[2] == "admin" then return cmd.admin end return not (cmd.hidden or cmd.admin) end local function do_sep(cmd) local first = cmd.hidden and "-H" or "--" local second = cmd.admin and "A-" or "--" return first .. second end if #cmdline == 1 or cmdline[2] == "admin" or cmdline[2] == "all" then -- List all commands local maxcmdn = 0 for i = 1, #cmds do local cmd = cmds[cmds[i]] local wanted = do_want(cmd) if wanted then if #cmd.name > maxcmdn then maxcmdn = #cmd.name end end end for i = 1, #cmds do local cmd = cmds[cmds[i]] local wanted = do_want(cmd) if wanted then local gap = (" "):rep(maxcmdn - #cmd.name) local desc = (cmd.short or "No description") if cmd.takes_repo then desc = desc .. " (Takes a repo)" end log.state(gap .. cmd.name, do_sep(cmd), desc) end end else local cmd = cmds[cmdline[2]] local desc = (cmd.short or "No description") if cmd.takes_repo then desc = desc .. " (Takes a repo)" end log.state(cmd.name, do_sep(cmd), desc) if cmd.helptext then log.state("") for line in (cmd.helptext):gmatch(pattern.TEXT_LINE) do log.state("=>", line) end end end return "exit", 0 end assert(register_cmd("help", builtin_help_short, builtin_help_helptext, builtin_help_validate, builtin_help_prep, builtin_help_run, false, false)) local function builtin_upload_pack_validate(config, repo, cmdline) -- git-upload-pack repo if #cmdline > 2 then return false end cmdline[2] = repo:fs_path() return true end local function builtin_upload_pack_prep(config, repo, cmdline, context) if repo.is_nascent then return "deny", "Repository " .. repo.name .. " does not exist" end -- git-upload-pack is always a read operation context.operation = "read" return repo:run_lace(context) end local function builtin_upload_pack_run(config, repo, cmdline, env) local cmdcopy = {"upload-pack", env=env} for i = 2, #cmdline do cmdcopy[i] = cmdline[i] end return repo:git_command(cmdcopy) end assert(register_cmd("git-upload-pack", nil, nil, builtin_upload_pack_validate, builtin_upload_pack_prep, builtin_upload_pack_run, true, true)) local function builtin_receive_pack_validate(config, repo, cmdline) -- git-receive-pack repo if #cmdline > 2 then return false end cmdline[2] = repo:fs_path() return true end local function builtin_receive_pack_prep(config, repo, cmdline, context) if repo.is_nascent then return "deny", "Repository " .. repo.name .. " does not exist" end -- git-receive-pack is always a simple write operation context.operation = "write" return repo:run_lace(context) end local function builtin_receive_pack_run(config, repo, cmdline, env) local cmdcopy = {"receive-pack", env=env} for i = 2, #cmdline do cmdcopy[i] = cmdline[i] end return repo:git_command(cmdcopy) end assert(register_cmd("git-receive-pack", nil, nil, builtin_receive_pack_validate, builtin_receive_pack_prep, builtin_receive_pack_run, true, true)) local builtin_create_short = "Create a new repository" local builtin_create_helptext = [[ usage: create [] Create a new repository, optionally setting its owner directly. In order to create a repository, the site administrators must grant you the ability in some part of the namespace. Specifying an owner is equivalent to creating the repository and then calling config set project.owner to re-assign it. ]] local function builtin_create_validate(config, repo, cmdline) -- create reponame if #cmdline > 3 then log.error("usage: create []") return false end if not repo then log.error("No repository?") return false end if repo and not repo.is_nascent then log.error("Repository", repo.name, "already exists") return false end return true end local function builtin_create_prep(config, repo, cmdline, context) context.operation = "createrepo" return repo:run_lace(context) end local function builtin_create_run(config, repo, cmdline, env) -- Realise the repository log.chat("Creating repository:", repo.name) local ok, msg = repo:realise() if not ok then log.error(msg) return "exit", 1 end local owner = cmdline[3] or env["GITANO_USER"] log.chat("Setting repository owner to", owner) ok, msg = repo:conf_set_and_save("project.owner", owner, env.GITANO_USER, env.GITANO_ORIG_USER) if not ok then log.error(msg) return "exit", 1 end log.chat("Running checks to ensure hooks etc are configured") ok, msg = repo:run_checks() if not ok then log.error(msg) return "exit", 1 end log.state("Repository", repo.name, "created ok. Remember to configure rules etc.") return "exit", 0 end assert(register_cmd("create", builtin_create_short, builtin_create_helptext, builtin_create_validate, builtin_create_prep, builtin_create_run, true, false)) local builtin_config_short = "View and change configuration for a repository" local builtin_config_helptext = [[ usage: config [args...] View and manipulate the configuration of a repository. * config show [...] List all configuration variables in which match any of the filters provided. The filters are prefixes which are matched against the keys of the configuration variables. For example: `config sampler list project` will list all the project configuration entries for the sampler.git repository. Keys which represent lists are shown as `foo.*` If you wish to show the detailed key, showing the index of the entry in the list then you should set the filter exactly to `foo.*` which will cause the show command to expand list keys into the form `foo.i_N` where N is the index in the list. For example: `config sampler set project.readers.* alice`, and `config sampler set project.readers.* bob` will add users alice and bob to the `project.readers` list for the sampler.git repository. * config set key value Set the given configuration key to the given value. If the key ends in `.*` then the system will add the given value to the end of the list represented by the key. To replace a specific entry, set the specific `i_N` entry to the value you want to replace it. * config {del,delete,rm} key Removes the given key from the configuration set. If the key ends in `.*` then the system will remove all configuration values below that prefix. To remove a specific element of a list, instead, be sure to delete the `i_N` entry instead. ]] local function builtin_config_validate(conf, repo, cmdline) if not repo or repo.is_nascent then log.error("Cannot access configuration of a nascent repository") return false end if #cmdline < 3 then cmdline[3] = "show" end if cmdline[3] == "show" then -- No validation to do yet elseif cmdline[3] == "set" then if #cmdline < 5 then log.error("config set: takes a key and a value to set") return false end if #cmdline > 5 then local cpy = {} for i = #cmdline, 5, -1 do table.insert(cpy, 1, cmdline[i]) cmdline[i] = nil end cmdline[5] = table.concat(cpy, " ") end if cmdline[4] == "project.owner" then -- Verify that the new owner is a valid user if not conf.users[cmdline[5]] then log.error("Unknown user: " .. cmdline[5]) return false end end elseif cmdline[3] == "del" or cmdline[3] == "delete" or cmdline[3] == "rm" then cmdline[3] = "del" if #cmdline ~= 4 then log.error("config del: takes a key and nothing more") return false end cmdline.orig_key = cmdline[4] if cmdline[4]:match(pattern.CONF_ENDS_WILDCARD) then -- Doing a wild removal, expand it now local prefix = cmdline[4]:match(pattern.CONF_WILDCARD) cmdline[4] = nil for k in repo.project_config:each(prefix) do cmdline[#cmdline+1] = k end end else log.error("Unknown subcommand <" .. cmdline[3] .. "> for config.") log.info("Valid subcommands are and ") return false end return true end local function builtin_config_prep(conf, repo, cmdline, context) if cmdline[3] == "show" then context.operation = "config_show" for i = 4, #cmdline do local cpy = util.deep_copy(context) cpy.key = cmdline[i] local action, reason = repo:run_lace(cpy) if action ~= "allow" then return action, reason end end return "allow", "Show not denied" elseif cmdline[3] == "set" then context.operation = "config_set" context.key = cmdline[4] context.value = cmdline[5] return repo:run_lace(context) elseif cmdline[3] == "del" then context.operation = "config_del" for i = 4, #cmdline do local cpy = util.deep_copy(context) cpy.key = cmdline[i] local action, reason = repo:run_lace(cpy) if action ~= "allow" then return action, reason end end return "allow", "Delete not denied" end return "deny", "Unknown sub command slipped through" end local function builtin_config_run(conf, repo, cmdline, env) if cmdline[3] == "show" then local all_keys = {} if #cmdline == 3 then for k in repo.project_config:each() do all_keys[k] = true end else for i = 4, #cmdline do for k in repo.project_config:each(cmdline[i]) do all_keys[k] = true end end end -- Transform the all_keys set into a sorted list local slist = {} for k in pairs(all_keys) do slist[#slist+1] = k end -- TODO: Fix this sort to cope with .i_N keys neatly table.sort(slist) for i = 1, #slist do local key = slist[i] local value = repo.project_config.settings[key] local prefix = key:match(pattern.CONF_ARRAY_INDEX) if prefix then local neatkey = prefix .. ".*" for i = 4, #cmdline do if cmdline[i] == neatkey then neatkey = key break end end end log.stdout(key .. ": " .. value) end if #slist == 0 then local keys = {} for i = 4, #cmdline do keys[#keys+1] = "'" .. cmdline[i] .. "'" end keys = table.concat(keys, ", ") log.error("Configuration keys not found: " .. keys) return "exit", 1 end elseif cmdline[3] == "set" then local key, value = cmdline[4], cmdline[5] local vtype, rest = value:match(pattern.CONF_SET_TYPE_PREFIX) if vtype then if vtype == "s" then value = rest end if vtype == "i" then value = tonumber(rest) end if vtype == "b" then value = ((rest:lower() == "true") or (rest == "1") or (rest:lower() == "on") or (rest:lower() == "yes")) end end repo.project_config.settings[key] = value local ok, msg = repo:save_admin("Changed project setting: " .. key, env.GITANO_USER, env.GITANO_ORIG_USER) if not ok then log.error(msg) return "exit", 2 end elseif cmdline[3] == "del" then local key = cmdline.orig_key for i = 4, #cmdline do repo.project_config.settings[cmdline[4]] = nil end local ok, msg = repo:save_admin("Deleted project setting: " .. key, env.GITANO_USER, env.GITANO_ORIG_USER) if not ok then log.error(msg) return "exit", 2 end else log.error("Unknown sub command slipped through") return "exit", 1 end return "exit", 0 end assert(register_cmd("config", builtin_config_short, builtin_config_helptext, builtin_config_validate, builtin_config_prep, builtin_config_run, true, false)) local builtin_destroy_short = "Destroy (delete) a repository" local builtin_destroy_helptext = [[ usage: destroy [confirmtoken] This command destroys a repository. Run without a confirmation token it will tell the caller what the confirmation token is for that repository. The caller will then run the destroy command again with the confirmation token if they really do wish to destroy the repository. Optionally, you can supply '--force' as the token to bypass the confirmation stage. It is not recommended to use this outside of automation. ]] local function builtin_destroy_validate(config, repo, cmdline) if #cmdline < 2 or #cmdline > 3 then log.error("Destroy takes a repository and a (optional) confirmation token") return false end if not repo or repo.is_nascent then log.error("Cannot destroy a repository which does not exist") return false end return true end local function builtin_destroy_prep(config, repo, cmdline, context) context.operation = "destroyrepo" return repo:run_lace(context) end local function builtin_destroy_run(config, repo, cmdline, env) local token = repo:generate_confirmation("destroy repo " .. repo.name) if #cmdline == 2 then -- Generate the confirmation token log.stdout("") log.stdout("If you are *certain* you wish to destroy this repository") log.stdout("Then re-run your command with the following confirmation token:") log.stdout("") log.stdout(" ", token) else if cmdline[3] ~= token and cmdline[3] ~= '--force' then log.error("Confirmation token does not match, refusing to destroy") return "exit", 1 end -- Tokens match, ask the repo to destroy itself local nowstamp = os.date("!%Y-%m-%d.%H:%M:%S.UTC") local ok, msg = repo:destroy_self(nowstamp .. "." .. (repo.name:gsub("[^A-Za-z0-9_%.%-]", "_")) .. "." .. token .. ".destroyed") if not ok then log.error(msg) return "exit", 1 end log.stdout("Should you need to recover the repository you just destroyed") log.stdout("then you will need to speak with an admin as soon as possible") log.stdout("") log.stdout("When you do, be sure to include the current time (" .. nowstamp .. ").") log.stdout("It may also help if you include your token:") log.stdout(" ", token) log.stdout("") log.stdout("Successfully destroyed", repo.name) end return "exit", 0 end assert(register_cmd("destroy", builtin_destroy_short, builtin_destroy_helptext, builtin_destroy_validate, builtin_destroy_prep, builtin_destroy_run, true, false)) local builtin_rename_short = "Rename a repository" local builtin_rename_helptext = [[ usage: rename Renames a repository to the given new name. In order to do this, you must have the ability to create repositories at the new name, the ability to read the current repository and the ability to rename the current repository. ]] local function builtin_rename_validate(config, repo, cmdline) if #cmdline ~= 3 then log.error("Rename takes a repository and a new name for it") return false end return true end local function builtin_rename_prep(config, repo, cmdline, context) local ctx, action, reason -- Check 1, read current repo ctx = util.deep_copy(context) ctx.operation = "read" action, reason = repo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Check 2, is the current repo nascent if repo.is_nascent then return "deny", "Cannot rename a repository which does not exist" end -- Check 3, rename current repo ctx = util.deep_copy(context) ctx.operation = "renamerepo" action, reason = repo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Check 4, create new repo ctx = util.deep_copy(context) local newrepo, msg = repository.find(config, cmdline[3]) if not newrepo then return "deny", msg end ctx.operation="createrepo" action, reason = newrepo:run_lace(ctx) if action ~= "allow" then return action, reason end -- Check 5, does new repo already exist? if not newrepo.is_nascent then return "deny", "Destination location is in use" end -- Okay, we could create, read, and destroy -- thus we can rename return "allow", "Passed all checks, can rename" end local function builtin_rename_run(config, repo, cmdline, env) local ok, msg = repo:rename_to(cmdline[3]) if not ok then log.error(msg) return "exit", 1 end log.state("Renamed", cmdline[2], "to", cmdline[3]) return "exit", 0 end assert(register_cmd("rename", builtin_rename_short, builtin_rename_helptext, builtin_rename_validate, builtin_rename_prep, builtin_rename_run, true, false)) local builtin_ls_short = "List repositories on the server" local builtin_ls_helptext = [[ usage: ls [--verbose|-v] [--all|-a] [...] List repositories on the server. If you do not provide a pattern then all repositories are considered, otherwise only ones which match the given patterns will be considered. If you specify --verbose then the head ref name and the description will be provided in addition to the access rights and repository name, separated by tabs. If you specify --all then repositories which you have access to, but have been marked as archived will also be displayed. Patterns are a type of extended glob style syntax: ? == any one character except / * == zero or more characters except / ** == zero or more characters including / Any other characters are "as-is" except \ which escapes the next character. If your pattern contains no / and no ** then it will be matched against leafnames of repositories. Note, this means that if you run `ls foo` then the server is going to look for repositories called `foo.git` rather than look in side a folder called `foo/`. For the latter, do `ls foo/` instead. ]] local function builtin_ls_validate(config, _, cmdline) -- For now, anything will do return true end local function builtin_ls_prep(config, _, cmdline, ctx) -- We cheat and store the context for later because we'll need it cmdline._ctx = ctx return "allow", "You can always try and get a listing" end local builtin_ls_special = {} do local builtin_ls_special_chrs = "*?.[]()+-%^$" for c in builtin_ls_special_chrs:gmatch(".") do builtin_ls_special[c] = true end end local lsargs = { ["--verbose"] = "verbose", ["-v"] = "verbose", ["--all"] = "all", ["-a"] = "all", } local function builtin_ls_run(config, _, cmdline, env) -- Step one, parse each pattern into a lua pattern local pats = {} local firstpat, verbose, all = 2, false, false while lsargs[cmdline[firstpat]] do if lsargs[cmdline[firstpat]] == "verbose" then firstpat, verbose = firstpat + 1, true end if lsargs[cmdline[firstpat]] == "all" then firstpat, all = firstpat + 1, true end end for i = firstpat, #cmdline do local pat, c, input = "", "", cmdline[i] local escaping, star, used_evil = false, false, false c, input = input:match("^(.)(.*)$") while c and c ~= "" do if escaping then pat = pat .. (builtin_ls_special[c] and "%" or "") .. c if c == "/" then used_evil = true end escaping = false else if c == "*" then if star then -- ** pat = pat .. ".*" used_evil = true star = false else star = true end else if star then -- * pat = pat .. "[^/]*" star = false end if c == "?" then pat = pat .. "[^/]" elseif c == "\\" then escaping = true else pat = pat .. (builtin_ls_special[c] and "%" or "") .. c if c == "/" then used_evil = true end end end end c, input = input:match("^(.)(.*)$") end if star then -- spare star pat = pat .. "[^/]*" end if cmdline[i]:match("/$") then pat = pat .. ".*" end if used_evil then pat = "^/" .. pat .. pattern.GIT_REPO_SUFFIX else pat = "/" .. pat .. pattern.GIT_REPO_SUFFIX end log.debug("PAT:", pat) pats[#pats+1] = pat end if #pats == 0 then pats[1] = "." end -- Now we iterate all the repositories, listing them if (a) they pass a -- pattern and (b) they allow the current user to read. local _ctx = cmdline._ctx local function filter_callback(name) for i = 1, #pats do if ("/" .. name):match(pats[i]) then return true end end end local function callback(reponame, repo, msg) if repo then local archived = repo:conf_get("project.archived") if archived and not all then return end local ctx = util.deep_copy(_ctx) ctx.operation = "read" local action, reason = repo:run_lace(ctx) if action == "allow" then ctx = util.deep_copy(_ctx) ctx.operation = "write" action, reason = repo:run_lace(ctx) local tail = "" if verbose then local desc = repo:conf_get("project.description") desc = desc:gsub("\n.*", "") tail = " " .. repo:conf_get("project.head") .. " " .. desc end log.stdout((action == "allow" and "RW" or "R ") .. (archived and "A" or " "), repo.name .. tail) end end end repository.foreach(config, callback, filter_callback) return "exit", 0 end assert(register_cmd("ls", builtin_ls_short, builtin_ls_helptext, builtin_ls_validate, builtin_ls_prep, builtin_ls_run, false, false)) local usercmds = require 'gitano.usercommand' usercmds.register(register_cmd) local admincmds = require 'gitano.admincommand' admincmds.register(register_cmd) local repocmds = require 'gitano.repocommand' repocmds.register(register_cmd) local copycmds = require 'gitano.copycommand' copycmds.register(register_cmd) return { register = register_cmd, get = get_cmd, }