377 lines
9.6 KiB
Go
377 lines
9.6 KiB
Go
package subproc
|
|
|
|
import (
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
gitTerminalPromptAssignment = "GIT_TERMINAL_PROMPT=0"
|
|
gcmInteractiveAssignment = "GCM_INTERACTIVE=Never"
|
|
gcmGUIPromptAssignment = "GCM_GUI_PROMPT=0"
|
|
gitAskpassAssignment = "GIT_ASKPASS=exit 1"
|
|
sshAskpassAssignment = "SSH_ASKPASS=exit 1"
|
|
sshAskpassRequireAssignment = "SSH_ASKPASS_REQUIRE=never"
|
|
defaultGitSSHCommand = "ssh -oBatchMode=yes"
|
|
)
|
|
|
|
// PrepareGitCommand applies the final managed Git policy after a caller has
|
|
// assembled its command environment. It preserves the caller's selected
|
|
// credential and configuration scope while overriding prompt controls.
|
|
func PrepareGitCommand(cmd *exec.Cmd) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
cmd.Env = prepareGitEnvironment(cmd.Environ())
|
|
}
|
|
|
|
// PrepareGitEnvironment applies the final managed Git policy to an explicit
|
|
// environment. A nil environment is treated as empty; command callers that
|
|
// need inherited values should pass os.Environ or use PrepareGitCommand.
|
|
func PrepareGitEnvironment(env []string) []string {
|
|
return prepareGitEnvironment(env)
|
|
}
|
|
|
|
func prepareGitEnvironment(env []string) []string {
|
|
prepared := collapseEnvironmentAssignments(env)
|
|
prepared = replaceEnvironmentAssignment(prepared, gitTerminalPromptAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, gcmInteractiveAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, gcmGUIPromptAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, gitAskpassAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, sshAskpassAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, sshAskpassRequireAssignment)
|
|
prepared = replaceEnvironmentAssignment(prepared, "GIT_SSH_COMMAND="+gitSSHCommand(prepared))
|
|
return prepared
|
|
}
|
|
|
|
func collapseEnvironmentAssignments(env []string) []string {
|
|
last := make(map[string]int, len(env))
|
|
for index, entry := range env {
|
|
key, ok := environmentAssignmentKey(entry)
|
|
if ok {
|
|
last[normalizeEnvironmentKey(key)] = index
|
|
}
|
|
}
|
|
result := make([]string, 0, len(env))
|
|
for index, entry := range env {
|
|
key, ok := environmentAssignmentKey(entry)
|
|
if ok && last[normalizeEnvironmentKey(key)] != index {
|
|
continue
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func replaceEnvironmentAssignment(env []string, assignment string) []string {
|
|
key, _, ok := strings.Cut(assignment, "=")
|
|
if !ok {
|
|
return env
|
|
}
|
|
result := make([]string, 0, len(env)+1)
|
|
for _, entry := range env {
|
|
entryKey, hasValue := environmentAssignmentKey(entry)
|
|
if hasValue && environmentKeysEqual(entryKey, key) {
|
|
continue
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return append(result, assignment)
|
|
}
|
|
|
|
func environmentAssignmentKey(entry string) (string, bool) {
|
|
key, _, ok := strings.Cut(entry, "=")
|
|
return key, ok && key != ""
|
|
}
|
|
|
|
func normalizeEnvironmentKey(key string) string {
|
|
if runtime.GOOS == "windows" {
|
|
return strings.ToUpper(key)
|
|
}
|
|
return key
|
|
}
|
|
|
|
func environmentKeysEqual(left, right string) bool {
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(left, right)
|
|
}
|
|
return left == right
|
|
}
|
|
|
|
func environmentValue(env []string, key string) (string, bool) {
|
|
for index := len(env) - 1; index >= 0; index-- {
|
|
entryKey, ok := environmentAssignmentKey(env[index])
|
|
if !ok || !environmentKeysEqual(entryKey, key) {
|
|
continue
|
|
}
|
|
return strings.TrimPrefix(env[index], entryKey+"="), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func gitSSHCommand(env []string) string {
|
|
if command, ok := environmentValue(env, "GIT_SSH_COMMAND"); ok && strings.TrimSpace(command) != "" {
|
|
return ForceGitSSHBatchMode(command)
|
|
}
|
|
if executable, ok := environmentValue(env, "GIT_SSH"); ok && strings.TrimSpace(executable) != "" {
|
|
return ForceGitSSHBatchMode(quoteSSHExecutable(executable))
|
|
}
|
|
return defaultGitSSHCommand
|
|
}
|
|
|
|
func quoteSSHExecutable(executable string) string {
|
|
executable = strings.TrimSpace(executable)
|
|
if executable == "" || (executable[0] == '\'' && executable[len(executable)-1] == '\'') ||
|
|
(executable[0] == '"' && executable[len(executable)-1] == '"') {
|
|
return executable
|
|
}
|
|
if strings.ContainsAny(executable, " \t\n\r") {
|
|
return "'" + strings.ReplaceAll(executable, "'", "'\\''") + "'"
|
|
}
|
|
return executable
|
|
}
|
|
|
|
// ForceGitSSHBatchMode places BatchMode=yes immediately after a direct
|
|
// OpenSSH executable. Unsupported wrappers use the safe default rather than
|
|
// receiving an option in an unknown position.
|
|
func ForceGitSSHBatchMode(command string) string {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return defaultGitSSHCommand
|
|
}
|
|
commandEnd := shellWordEnd(command)
|
|
if commandEnd == 0 {
|
|
return defaultGitSSHCommand
|
|
}
|
|
executable, ok := shellWordValue(command[:commandEnd])
|
|
if !ok || !isOpenSSHExecutable(executable) {
|
|
return defaultGitSSHCommand
|
|
}
|
|
command = removeBatchModeYes(command, commandEnd)
|
|
return command[:commandEnd] + " -oBatchMode=yes" + command[commandEnd:]
|
|
}
|
|
|
|
func removeBatchModeYes(command string, commandEnd int) string {
|
|
words, ok := parseShellWords(command, commandEnd)
|
|
if !ok {
|
|
return command
|
|
}
|
|
|
|
removals := make([]shellRange, 0, len(words))
|
|
for index := 0; index < len(words); index++ {
|
|
word := words[index]
|
|
end := word.end
|
|
remove := false
|
|
switch word.value {
|
|
case "-oBatchMode=yes":
|
|
// Match the decoded value so shell quoting around the option is
|
|
// removed together with the option itself.
|
|
remove = true
|
|
case "-o":
|
|
if index+1 < len(words) && words[index+1].value == "BatchMode=yes" {
|
|
end = words[index+1].end
|
|
index++
|
|
remove = true
|
|
}
|
|
}
|
|
if !remove {
|
|
continue
|
|
}
|
|
|
|
start := word.start
|
|
for start > commandEnd && isShellSpace(command[start-1]) {
|
|
start--
|
|
}
|
|
removals = append(removals, shellRange{start: start, end: end})
|
|
}
|
|
|
|
if len(removals) == 0 {
|
|
return command
|
|
}
|
|
var result strings.Builder
|
|
result.Grow(len(command))
|
|
cursor := 0
|
|
for _, removal := range removals {
|
|
if removal.start < cursor {
|
|
continue
|
|
}
|
|
result.WriteString(command[cursor:removal.start])
|
|
cursor = removal.end
|
|
}
|
|
result.WriteString(command[cursor:])
|
|
return result.String()
|
|
}
|
|
|
|
type shellRange struct {
|
|
start int
|
|
end int
|
|
}
|
|
|
|
type shellWord struct {
|
|
start int
|
|
end int
|
|
value string
|
|
}
|
|
|
|
func parseShellWords(command string, offset int) ([]shellWord, bool) {
|
|
words := make([]shellWord, 0)
|
|
for offset < len(command) {
|
|
for offset < len(command) && isShellSpace(command[offset]) {
|
|
offset++
|
|
}
|
|
if offset == len(command) {
|
|
break
|
|
}
|
|
|
|
word, next, ok := parseShellWord(command, offset)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
words = append(words, word)
|
|
offset = next
|
|
}
|
|
return words, true
|
|
}
|
|
|
|
func parseShellWord(command string, start int) (shellWord, int, bool) {
|
|
offset := start
|
|
var value strings.Builder
|
|
for offset < len(command) && !isShellSpace(command[offset]) {
|
|
next, ok := parseShellWordPart(command, offset, &value)
|
|
if !ok {
|
|
return shellWord{}, 0, false
|
|
}
|
|
offset = next
|
|
}
|
|
return shellWord{start: start, end: offset, value: value.String()}, offset, true
|
|
}
|
|
|
|
func parseShellWordPart(command string, offset int, value *strings.Builder) (int, bool) {
|
|
switch command[offset] {
|
|
case '\'':
|
|
return parseSingleQuotedShellWordPart(command, offset, value)
|
|
case '"':
|
|
return parseDoubleQuotedShellWordPart(command, offset, value)
|
|
case '\\':
|
|
return parseEscapedShellWordPart(command, offset, value)
|
|
default:
|
|
value.WriteByte(command[offset])
|
|
return offset + 1, true
|
|
}
|
|
}
|
|
|
|
func parseSingleQuotedShellWordPart(command string, offset int, value *strings.Builder) (int, bool) {
|
|
offset++
|
|
for offset < len(command) && command[offset] != '\'' {
|
|
value.WriteByte(command[offset])
|
|
offset++
|
|
}
|
|
if offset == len(command) {
|
|
return 0, false
|
|
}
|
|
return offset + 1, true
|
|
}
|
|
|
|
func parseDoubleQuotedShellWordPart(command string, offset int, value *strings.Builder) (int, bool) {
|
|
offset++
|
|
for offset < len(command) {
|
|
if command[offset] == '"' {
|
|
return offset + 1, true
|
|
}
|
|
if command[offset] == '\\' {
|
|
next, ok := parseDoubleQuotedEscape(command, offset, value)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
offset = next
|
|
continue
|
|
}
|
|
value.WriteByte(command[offset])
|
|
offset++
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func parseDoubleQuotedEscape(command string, offset int, value *strings.Builder) (int, bool) {
|
|
if offset+1 >= len(command) {
|
|
return 0, false
|
|
}
|
|
if command[offset+1] != '\n' {
|
|
value.WriteByte(command[offset+1])
|
|
}
|
|
return offset + 2, true
|
|
}
|
|
|
|
func parseEscapedShellWordPart(command string, offset int, value *strings.Builder) (int, bool) {
|
|
if offset+1 >= len(command) {
|
|
return 0, false
|
|
}
|
|
if command[offset+1] != '\n' {
|
|
value.WriteByte(command[offset+1])
|
|
}
|
|
return offset + 2, true
|
|
}
|
|
|
|
func isShellSpace(character byte) bool {
|
|
switch character {
|
|
case ' ', '\t', '\n', '\r':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func shellWordValue(word string) (string, bool) {
|
|
if word == "" {
|
|
return "", false
|
|
}
|
|
if word[0] == '\'' || word[0] == '"' {
|
|
if len(word) < 2 || word[len(word)-1] != word[0] {
|
|
return "", false
|
|
}
|
|
return word[1 : len(word)-1], true
|
|
}
|
|
if strings.ContainsAny(word, "'\"") {
|
|
return "", false
|
|
}
|
|
return word, true
|
|
}
|
|
|
|
func isOpenSSHExecutable(executable string) bool {
|
|
lastSeparator := strings.LastIndexAny(executable, `/\\`)
|
|
base := executable[lastSeparator+1:]
|
|
return base == "ssh" || base == "ssh.exe"
|
|
}
|
|
|
|
func shellWordEnd(command string) int {
|
|
var quote byte
|
|
escaped := false
|
|
for index := 0; index < len(command); index++ {
|
|
character := command[index]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if quote != 0 {
|
|
if quote == '"' && character == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if character == quote {
|
|
quote = 0
|
|
}
|
|
continue
|
|
}
|
|
switch character {
|
|
case '\\':
|
|
escaped = true
|
|
case '\'', '"':
|
|
quote = character
|
|
case ' ', '\t', '\n', '\r':
|
|
return index
|
|
}
|
|
}
|
|
return len(command)
|
|
}
|