`push` runs `git add --all .`, so anything lying in the project gets committed, and with `mirror = true` it reaches a public server in the same breath. It is the one action in mgsh that cannot be undone: a deleted server repository comes back from an archive, a published credential does not. The staged diff is now scanned before the commit is made -- private keys, GitHub/GitLab/Slack/AWS/PyPI tokens, and credential-shaped assignments -- and a hit is shown with file and line before asking whether to continue. Declining leaves the changes staged but uncommitted, so removing the file and adding a .gitignore entry is all it takes. The hard part is not detection but silence. A scanner that cries wolf gets answered with a reflexive "y" and stops being a safety net, so values that are plainly environment references, dotted identifiers, constant names, template slots or masked stand-ins are filtered out. A test scans mgsh's own README and mgshrc.example -- both full of credential-shaped text -- and fails if either would trip the check. It caught the documentation for this very feature, which is why the README describes the sample output instead of reproducing it. For a line that legitimately looks like a credential there is `mgsh:allow`, which suppresses that one line; `secretscan = off` turns the check off entirely. Only an explicit "off" does that -- a typo in the setting leaves the safety net in place, which is what the new falsy() is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
15 KiB
Go
450 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// configMode is the permission mask for ~/.mgshrc. The file holds the
|
|
// 'remotekey' API token, so it must not be readable by other local users.
|
|
const configMode = 0o600
|
|
|
|
// projectRC is the per-project configuration file, read from the active
|
|
// project directory and overlaid on the global settings.
|
|
const projectRC = ".mgshrc"
|
|
|
|
// Config holds all externally configurable settings. It is read from ~/.mgshrc,
|
|
// overlaid with the active project's own .mgshrc and then with MGSH_*
|
|
// environment variables — there are no built-in defaults. Missing required
|
|
// settings are a fatal error (see missingRequired).
|
|
type Config struct {
|
|
Base string // project base directory (global only)
|
|
GitHost string // git server host
|
|
GitPort string // ssh port
|
|
GitUser string // ssh user
|
|
GitPath string // remote path holding the bare repos
|
|
GitKey string // ssh identity for the git server ("" = ssh defaults)
|
|
GitName string // git user.name to set globally ("" = leave alone)
|
|
GitEmail string // git user.email to set globally ("" = leave alone)
|
|
PushDefault string // git push.default to set globally ("" = leave alone)
|
|
Editor string // editor/opener used as fallback by `open` ("" = coda)
|
|
RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab)
|
|
RemoteKey string // API token for the mirror server (used by `pushremote`)
|
|
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
|
|
RemoteVis string // visibility of created repos: "private" (default)|"public"
|
|
Mirror string // truthy -> `push` also mirrors via `pushremote`
|
|
SecretScan string // falsy -> `push` skips the credential scan
|
|
Remotes []RemoteTarget
|
|
RemoteNames string // "remotes": explicit, ordered subset of targets to use
|
|
}
|
|
|
|
// RemoteTarget is one public mirror server for `pushremote`, configured as a
|
|
// `remote.<name>.<field>` block. Name doubles as the git remote name created in
|
|
// the repository, so several targets can coexist side by side.
|
|
type RemoteTarget struct {
|
|
Name string
|
|
URL string
|
|
Key string
|
|
Type string // "gitea"|"github"|"gitlab" (auto-detected when empty)
|
|
Vis string // "private" (default) | "public"
|
|
}
|
|
|
|
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair,
|
|
// matching the git remote that earlier versions created.
|
|
const legacyRemoteName = "public"
|
|
|
|
// mirrorTargets returns the usable mirror targets in configured order, plus the
|
|
// names of targets that are defined but unusable (missing url or key) so the
|
|
// caller can complain about them instead of silently skipping.
|
|
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
|
|
var all []RemoteTarget
|
|
if c.RemoteURL != "" || c.RemoteKey != "" {
|
|
all = append(all, RemoteTarget{
|
|
Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey,
|
|
Type: c.RemoteType, Vis: c.RemoteVis,
|
|
})
|
|
}
|
|
all = append(all, c.Remotes...)
|
|
|
|
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
|
|
// to mirror to only some of the globally configured servers.
|
|
if sel := splitList(c.RemoteNames); len(sel) > 0 {
|
|
var picked []RemoteTarget
|
|
for _, n := range sel {
|
|
for _, t := range all {
|
|
if strings.EqualFold(t.Name, n) {
|
|
picked = append(picked, t)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
all = picked
|
|
}
|
|
|
|
for _, t := range all {
|
|
if t.URL == "" || t.Key == "" {
|
|
incomplete = append(incomplete, t.Name)
|
|
continue
|
|
}
|
|
usable = append(usable, t)
|
|
}
|
|
return usable, incomplete
|
|
}
|
|
|
|
// splitList splits a comma- or whitespace-separated setting into its items.
|
|
func splitList(s string) []string {
|
|
var out []string
|
|
for _, f := range strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t'
|
|
}) {
|
|
if f != "" {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// requiredKeys lists the settings mgsh cannot run without.
|
|
var requiredKeys = []string{"base", "githost", "gitport", "gituser", "gitpath"}
|
|
|
|
// missingRequired reports which required settings are still unset. mgsh refuses
|
|
// to start while this is non-empty.
|
|
func (c Config) missingRequired() []string {
|
|
have := map[string]string{
|
|
"base": c.Base, "githost": c.GitHost, "gitport": c.GitPort,
|
|
"gituser": c.GitUser, "gitpath": c.GitPath,
|
|
}
|
|
var missing []string
|
|
for _, k := range requiredKeys {
|
|
if have[k] == "" {
|
|
missing = append(missing, k)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// configFile returns the path of the user config file (~/.mgshrc).
|
|
func configFile() string {
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".mgshrc")
|
|
}
|
|
|
|
// loadConfig writes an empty ~/.mgshrc template on first run, then returns the
|
|
// configuration parsed from it, overlaid with MGSH_* environment variables.
|
|
// It applies no defaults; validation of required settings is the caller's job.
|
|
func loadConfig() Config {
|
|
path := configFile()
|
|
if !fileExists(path) {
|
|
writeConfigTemplate(path)
|
|
}
|
|
var c Config
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
m := parseConfig(string(data))
|
|
applyConfig(&c, m)
|
|
warnConfigPerms(path, m)
|
|
}
|
|
applyEnv(&c)
|
|
return c
|
|
}
|
|
|
|
// projectGlobalOnly lists settings a project-level .mgshrc must not change:
|
|
// `base` decides where projects live in the first place, and the git identity
|
|
// keys are written to the user's *global* git config at startup — applying
|
|
// those per project would rewrite ~/.gitconfig on every `cd`.
|
|
var projectGlobalOnly = []string{"base", "gitname", "gitemail", "pushdefault"}
|
|
|
|
// resolveConfig returns the effective configuration for a project directory:
|
|
// the global settings, overlaid with the project's own .mgshrc, with MGSH_*
|
|
// applied last so an explicit environment override still wins. dir may be ""
|
|
// (no project active), which yields the global configuration unchanged.
|
|
func resolveConfig(base Config, dir string) Config {
|
|
c := base
|
|
// Remotes is a slice: copy it, or a project overlay would write through the
|
|
// shared backing array into the global configuration.
|
|
c.Remotes = append([]RemoteTarget(nil), base.Remotes...)
|
|
if dir == "" {
|
|
return c
|
|
}
|
|
|
|
path := filepath.Join(dir, projectRC)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return c
|
|
}
|
|
m := parseConfig(string(data))
|
|
|
|
var ignored []string
|
|
for _, k := range projectGlobalOnly {
|
|
if _, ok := m[k]; ok {
|
|
ignored = append(ignored, k)
|
|
delete(m, k)
|
|
}
|
|
}
|
|
if len(ignored) > 0 {
|
|
errorln(fmt.Sprintf("%s: ignoring global-only settings: %s",
|
|
path, strings.Join(ignored, ", ")))
|
|
}
|
|
|
|
applyConfig(&c, m)
|
|
applyEnv(&c)
|
|
warnConfigPerms(path, m)
|
|
warnConfigTracked(dir, m)
|
|
return c
|
|
}
|
|
|
|
// hasSecret reports whether a parsed config assigns an API token.
|
|
func hasSecret(m map[string]string) bool {
|
|
if m["remotekey"] != "" {
|
|
return true
|
|
}
|
|
for k, v := range m {
|
|
if v != "" && strings.HasSuffix(k, ".key") && remoteFieldRe.MatchString(k) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// warnConfigPerms complains when a config file holding an API token is readable
|
|
// by anyone else. New files are created 0600, but a file written by an earlier
|
|
// version — or by hand — is not silently re-chmodded behind the user's back.
|
|
func warnConfigPerms(path string, m map[string]string) {
|
|
if !hasSecret(m) {
|
|
return
|
|
}
|
|
fi, err := os.Stat(path)
|
|
if err != nil || fi.Mode().Perm()&0o077 == 0 {
|
|
return
|
|
}
|
|
errorln(fmt.Sprintf("warning: %s holds an API token but is mode %04o — run: chmod 600 %s",
|
|
path, fi.Mode().Perm(), path))
|
|
}
|
|
|
|
// warnConfigTracked complains when a project .mgshrc holds an API token and git
|
|
// would happily commit it. A project config is a normal file in the working
|
|
// tree and `push` commits everything, so this is an easy way to publish a token
|
|
// by accident — including on the very first commit made by `init`, which is why
|
|
// a project without a repository yet is checked too.
|
|
func warnConfigTracked(dir string, m map[string]string) {
|
|
if !hasSecret(m) || projectRCIgnored(dir) {
|
|
return
|
|
}
|
|
errorln("warning: " + filepath.Join(dir, projectRC) +
|
|
" holds an API token and is not git-ignored — add it to .gitignore")
|
|
}
|
|
|
|
// projectRCIgnored reports whether git would leave the project config out of a
|
|
// commit. In a repository git itself answers; before `init` there is no
|
|
// repository yet, so the .gitignore that init would use is read directly.
|
|
func projectRCIgnored(dir string) bool {
|
|
if isDir(dir + "/.git") {
|
|
return runQuiet(dir, "git", "check-ignore", "-q", projectRC) == nil
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(dir, ".gitignore"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, ln := range strings.Split(string(data), "\n") {
|
|
switch strings.TrimSpace(ln) {
|
|
case projectRC, "/" + projectRC:
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// writeConfigTemplate creates a blank, annotated ~/.mgshrc for the user to fill
|
|
// in, migrating any aliases from the legacy ~/.mgsh_aliases file. It writes no
|
|
// real values — mgsh has no built-in configuration.
|
|
func writeConfigTemplate(path string) {
|
|
var b strings.Builder
|
|
b.WriteString("# mgsh configuration — fill in the required settings, then run mgsh again.\n")
|
|
b.WriteString("# Format: 'key = value' (or 'key: value'); '#' starts a comment.\n")
|
|
b.WriteString("# MGSH_* environment variables override these settings.\n\n")
|
|
b.WriteString("# --- required ---\n")
|
|
b.WriteString("base =\n")
|
|
b.WriteString("githost =\n")
|
|
b.WriteString("gitport =\n")
|
|
b.WriteString("gituser =\n")
|
|
b.WriteString("gitpath =\n\n")
|
|
b.WriteString("# --- optional ---\n")
|
|
b.WriteString("# gitkey =\n")
|
|
b.WriteString("# gitname = Your Name\n")
|
|
b.WriteString("# gitemail = you@example.com\n")
|
|
b.WriteString("# pushdefault = matching\n")
|
|
b.WriteString("# editor = code\n\n")
|
|
b.WriteString("# --- public mirrors for `pushremote` ---\n")
|
|
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n")
|
|
b.WriteString("# `pushremote @hub` to a single one.\n")
|
|
b.WriteString("# remote.hub.url = https://github.com\n")
|
|
b.WriteString("# remote.hub.key = <personal-access-token>\n")
|
|
b.WriteString("# remote.hub.visibility = public\n")
|
|
b.WriteString("# remotes = hub # optional: restrict/order the set\n")
|
|
b.WriteString("# mirror = true # `push` also mirrors\n\n")
|
|
b.WriteString("# A project may override any of these (except base and the git\n")
|
|
b.WriteString("# identity) in its own <project>/.mgshrc.\n")
|
|
|
|
legacy := readLegacyAliases()
|
|
if blk := aliasBlock(legacy); blk != "" {
|
|
b.WriteString("\n" + blk)
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
|
|
errorln("could not create " + path + ": " + err.Error())
|
|
return
|
|
}
|
|
if len(legacy) > 0 {
|
|
os.Remove(legacyAliasFile()) // aliases now live in ~/.mgshrc
|
|
}
|
|
}
|
|
|
|
// parseConfig reads simple `key = value` (or `key: value`) lines, ignoring
|
|
// blank lines, `#` comments and `alias` definitions. Keys are lower-cased;
|
|
// values are unquoted.
|
|
func parseConfig(s string) map[string]string {
|
|
m := map[string]string{}
|
|
sc := bufio.NewScanner(strings.NewReader(s))
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if f := strings.Fields(line); len(f) > 0 && f[0] == "alias" {
|
|
continue // alias definitions are handled by the alias loader
|
|
}
|
|
i := strings.IndexAny(line, "=:")
|
|
if i < 0 {
|
|
continue
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(line[:i]))
|
|
val := stripInlineComment(strings.TrimSpace(line[i+1:]))
|
|
m[key] = strings.Trim(val, "\"'")
|
|
}
|
|
return m
|
|
}
|
|
|
|
// stripInlineComment removes a trailing `#` comment from a config value, as
|
|
// documented in the README and the generated template. The '#' must follow
|
|
// whitespace, so a value may still contain a literal '#' (an API token, a URL
|
|
// fragment). A quoted value is taken verbatim up to its closing quote.
|
|
func stripInlineComment(v string) string {
|
|
if strings.HasPrefix(v, "#") {
|
|
return ""
|
|
}
|
|
if len(v) > 1 && (v[0] == '"' || v[0] == '\'') {
|
|
if j := strings.IndexByte(v[1:], v[0]); j >= 0 {
|
|
return v[:j+2]
|
|
}
|
|
}
|
|
for i := 1; i < len(v); i++ {
|
|
if v[i] == '#' && (v[i-1] == ' ' || v[i-1] == '\t') {
|
|
return strings.TrimRight(v[:i], " \t")
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func applyConfig(c *Config, m map[string]string) {
|
|
set := func(key string, dst *string) {
|
|
if v, ok := m[key]; ok && v != "" {
|
|
*dst = v
|
|
}
|
|
}
|
|
set("base", &c.Base)
|
|
set("githost", &c.GitHost)
|
|
set("gitport", &c.GitPort)
|
|
set("gituser", &c.GitUser)
|
|
set("gitpath", &c.GitPath)
|
|
set("gitkey", &c.GitKey)
|
|
set("gitname", &c.GitName)
|
|
set("gitemail", &c.GitEmail)
|
|
set("pushdefault", &c.PushDefault)
|
|
set("editor", &c.Editor)
|
|
set("remoteurl", &c.RemoteURL)
|
|
set("remotekey", &c.RemoteKey)
|
|
set("remotetype", &c.RemoteType)
|
|
set("remotevisibility", &c.RemoteVis)
|
|
set("remotes", &c.RemoteNames)
|
|
set("mirror", &c.Mirror)
|
|
set("secretscan", &c.SecretScan)
|
|
applyRemoteTargets(c, m)
|
|
}
|
|
|
|
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
|
|
var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`)
|
|
|
|
// applyRemoteTargets merges `remote.<name>.<field>` settings into c.Remotes.
|
|
// An already known target is updated field by field, so a project .mgshrc can
|
|
// override just the visibility of a globally configured server. New targets are
|
|
// appended in key order, which keeps the push order deterministic.
|
|
func applyRemoteTargets(c *Config, m map[string]string) {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
if remoteFieldRe.MatchString(k) {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
for _, k := range keys {
|
|
v := m[k]
|
|
if v == "" {
|
|
continue
|
|
}
|
|
f := remoteFieldRe.FindStringSubmatch(k)
|
|
t := &c.Remotes[c.remoteIndex(f[1])]
|
|
switch f[2] {
|
|
case "url":
|
|
t.URL = v
|
|
case "key":
|
|
t.Key = v
|
|
case "type":
|
|
t.Type = v
|
|
case "visibility":
|
|
t.Vis = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// remoteIndex returns the position of the named target, appending an empty one
|
|
// when it is not there yet.
|
|
func (c *Config) remoteIndex(name string) int {
|
|
for i := range c.Remotes {
|
|
if c.Remotes[i].Name == name {
|
|
return i
|
|
}
|
|
}
|
|
c.Remotes = append(c.Remotes, RemoteTarget{Name: name})
|
|
return len(c.Remotes) - 1
|
|
}
|
|
|
|
func applyEnv(c *Config) {
|
|
env := func(key string, dst *string) {
|
|
if v := os.Getenv(key); v != "" {
|
|
*dst = v
|
|
}
|
|
}
|
|
env("MGSH_BASE", &c.Base)
|
|
env("MGSH_GITHOST", &c.GitHost)
|
|
env("MGSH_GITPORT", &c.GitPort)
|
|
env("MGSH_GITUSER", &c.GitUser)
|
|
env("MGSH_GITPATH", &c.GitPath)
|
|
env("MGSH_GITKEY", &c.GitKey)
|
|
env("MGSH_GITNAME", &c.GitName)
|
|
env("MGSH_GITEMAIL", &c.GitEmail)
|
|
env("MGSH_PUSHDEFAULT", &c.PushDefault)
|
|
env("MGSH_EDITOR", &c.Editor)
|
|
env("MGSH_REMOTEURL", &c.RemoteURL)
|
|
env("MGSH_REMOTEKEY", &c.RemoteKey)
|
|
env("MGSH_REMOTETYPE", &c.RemoteType)
|
|
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
|
|
env("MGSH_REMOTES", &c.RemoteNames)
|
|
env("MGSH_MIRROR", &c.Mirror)
|
|
env("MGSH_SECRETSCAN", &c.SecretScan)
|
|
}
|