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) }