235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// runInDir runs a command with inherited stdio, optionally in dir.
|
|
func runInDir(dir, name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
return c.Run()
|
|
}
|
|
|
|
// runQuiet runs a command in dir with its output discarded, for probes whose
|
|
// exit status is the only interesting part.
|
|
func runQuiet(dir, name string, args ...string) error {
|
|
c := exec.Command(name, args...)
|
|
c.Dir = dir
|
|
return c.Run()
|
|
}
|
|
|
|
// sshKeyPath returns the identity file to authenticate with at the git server,
|
|
// or "" when no 'gitkey' is configured. A bare name is looked up in ~/.ssh, a
|
|
// path (absolute or ~/-relative) is used as given.
|
|
func sshKeyPath() string {
|
|
k := strings.TrimSpace(cfg.GitKey)
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
switch {
|
|
case strings.HasPrefix(k, "~/"):
|
|
return filepath.Join(home, k[2:])
|
|
case strings.ContainsRune(k, '/'):
|
|
return k
|
|
default:
|
|
return filepath.Join(home, ".ssh", k)
|
|
}
|
|
}
|
|
|
|
// sshArgs prefixes the ssh options shared by every connection to the git
|
|
// server: the configured port and, when set, the identity file.
|
|
func sshArgs(rest ...string) []string {
|
|
args := []string{"-p", cfg.GitPort}
|
|
if k := sshKeyPath(); k != "" {
|
|
args = append(args, "-i", k)
|
|
}
|
|
return append(args, rest...)
|
|
}
|
|
|
|
// gitEnv is the environment for a git subprocess: nil (inherit) unless a
|
|
// 'gitkey' is configured, in which case git's ssh transport is pointed at it.
|
|
// Set per command instead of in mgsh's own environment, so a `!git ...` shell
|
|
// escape keeps whatever the user's shell would normally do.
|
|
func gitEnv() []string {
|
|
k := sshKeyPath()
|
|
if k == "" {
|
|
return nil
|
|
}
|
|
return append(os.Environ(), "GIT_SSH_COMMAND=ssh -i "+shq(k))
|
|
}
|
|
|
|
// git runs a git command with inherited stdio, optionally in dir.
|
|
func git(dir string, args ...string) error {
|
|
c := exec.Command("git", args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
c.Env = gitEnv()
|
|
return c.Run()
|
|
}
|
|
|
|
// gitOK runs git and, on failure, prints a red summary. Returns success.
|
|
func gitOK(dir string, args ...string) bool {
|
|
if err := git(dir, args...); err != nil {
|
|
errorln("git " + strings.Join(args, " ") + " failed")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func gitCapture(dir string, args ...string) (string, error) {
|
|
c := exec.Command("git", args...)
|
|
if dir != "" {
|
|
c.Dir = dir
|
|
}
|
|
c.Env = gitEnv()
|
|
out, err := c.Output()
|
|
return string(out), err
|
|
}
|
|
|
|
// shq quotes s for interpolation into a remote /bin/sh command line. Every
|
|
// remote command is a single string handed to the login shell, so any project
|
|
// name or configured path reaching it must go through here — otherwise a space
|
|
// splits one argument into two and a backtick runs on the server.
|
|
func shq(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// sshExec runs one command on the git server. Every remote command mgsh issues
|
|
// — including the `rm -rf` in `init` — funnels through here, which is why it is
|
|
// a variable: tests replace it with a recorder and assert on exactly what would
|
|
// reach the server. With capture set the output is returned, otherwise the
|
|
// command inherits mgsh's stdio.
|
|
var sshExec = func(remote string, capture bool) ([]byte, error) {
|
|
args := sshArgs(cfg.GitUser+"@"+cfg.GitHost, remote)
|
|
c := exec.Command("ssh", args...)
|
|
if !capture {
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
return nil, c.Run()
|
|
}
|
|
c.Stderr = os.Stderr
|
|
out, err := c.Output()
|
|
return out, err
|
|
}
|
|
|
|
// errRemoteBang is what a command carrying a '!' comes back with, so a caller
|
|
// sees a failure rather than a command that quietly did something else.
|
|
var errRemoteBang = errors.New("remote command contains '!'")
|
|
|
|
// remoteRejected reports whether a command must not be sent at all, and says
|
|
// why. '!' is the one character shq cannot protect: csh expands history *before*
|
|
// it looks at quotes, and it does so non-interactively too — `echo 'fix!now'`
|
|
// answers "Event not found" on the tcsh server. No spelling survives both csh
|
|
// and sh, so the only safe move is not to send one. This is the place every
|
|
// remote command passes.
|
|
func remoteRejected(remote string) bool {
|
|
if !strings.ContainsRune(remote, '!') {
|
|
return false
|
|
}
|
|
errorln("not sending a command with '!' in it — the server's login shell " +
|
|
"would expand it instead of passing it on: " + remote)
|
|
return true
|
|
}
|
|
|
|
// ssh runs a single remote command over ssh with inherited stdio.
|
|
func ssh(remote string) error {
|
|
if remoteRejected(remote) {
|
|
return errRemoteBang
|
|
}
|
|
_, err := sshExec(remote, false)
|
|
return err
|
|
}
|
|
|
|
// sshOK runs a remote command and, on failure, prints a red summary.
|
|
func sshOK(remote string) bool {
|
|
if err := ssh(remote); err != nil {
|
|
errorln("ssh failed: " + remote)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sshOut runs a remote command and returns its stdout split into lines.
|
|
func sshOut(remote string) ([]string, error) {
|
|
if remoteRejected(remote) {
|
|
return nil, errRemoteBang
|
|
}
|
|
out, err := sshExec(remote, true)
|
|
lines := strings.Split(string(out), "\n")
|
|
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines, err
|
|
}
|
|
|
|
// serverPath anchors a path on the git server at the configured gitpath.
|
|
//
|
|
// Every remote command has to name its target outright, because the login
|
|
// directory of the git user is not necessarily the directory holding the bare
|
|
// repositories. With `gituser = git` and `gitpath = /home/git` the two are the
|
|
// same place and a bare "." worked by luck; with `gituser = root` and
|
|
// `gitpath = /root/mgsh` it lists the home directory, where there is nothing to
|
|
// find.
|
|
func serverPath(rel string) string {
|
|
base := strings.TrimRight(cfg.GitPath, "/")
|
|
rel = strings.TrimPrefix(strings.TrimSpace(rel), "./")
|
|
if rel == "" || rel == "." {
|
|
return base
|
|
}
|
|
return base + "/" + rel
|
|
}
|
|
|
|
// serverRepoNames lists the bare repositories on the git server, without the
|
|
// ".git" suffix.
|
|
func serverRepoNames() ([]string, error) {
|
|
lines, err := sshOut("/bin/ls " + shq(serverPath(".")))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, ln := range lines {
|
|
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
|
|
out = append(out, m[1])
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// serverEntryExists reports whether entry is present in the remote directory
|
|
// path (relative to gitpath, which serverPath resolves). The error is returned
|
|
// rather than folded into the bool so a failed lookup is never mistaken for
|
|
// "not there".
|
|
func serverEntryExists(path, entry string) (bool, error) {
|
|
lines, err := sshOut("/bin/ls " + shq(serverPath(path)))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, ln := range lines {
|
|
if strings.TrimSpace(ln) == entry {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// forwardShell runs a line via /bin/sh -c in the active project directory.
|
|
func forwardShell(line string) {
|
|
runInDir(DIR, "/bin/sh", "-c", line)
|
|
}
|