Files
mgsh/git.go
T
mikeandClaude Opus 5 689a3fe594 Make the ssh layer injectable and cover the destructive server commands
The remote commands are the only code in mgsh that can destroy data, and
they were the least verifiable: each one is a single string handed to a
login shell, so a missing quote silently changes which paths it touches.
Every one of them now goes through the sshExec variable, and yesno is a
variable too, so a test can record what would have been sent and answer
the confirmations without a terminal.

The tests pin down what the previous commit fixed by reasoning alone:
that a declined or unreachable `init` sends no rm -rf, that a project
named "my 'weird' project" reaches the server fully quoted, and that
archive's cp/tar/rm sequence is named, ordered and quoted correctly.
push is driven end to end against a real local bare repository.

archive also gained the server-side existence check that init, clone and
show already had, so a project that was never pushed reports that instead
of failing inside cp -r.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:38:22 +02:00

175 lines
4.8 KiB
Go

package main
import (
"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
}
// ssh runs a single remote command over ssh with inherited stdio.
func ssh(remote string) error {
_, 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) {
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
}
// serverEntryExists reports whether entry is present in the remote directory
// path (relative to the git user's home). 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(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)
}