You've already forked agentic-coding-workflow
feat(infra/git): Runner interface + Credential injection + GitError
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// CredentialKind 与 DB 列 git_credentials.kind 完全一致。
|
||||
type CredentialKind string
|
||||
|
||||
const (
|
||||
CredentialPAT CredentialKind = "pat"
|
||||
CredentialSSHKey CredentialKind = "ssh_key"
|
||||
CredentialNone CredentialKind = "none"
|
||||
)
|
||||
|
||||
// Credential 携带解密后的明文 secret。Service 层从 git_credentials 行 + crypto.Encryptor.Decrypt
|
||||
// 解出后构造 Credential,git 调用结束立即丢弃。
|
||||
type Credential struct {
|
||||
Kind CredentialKind
|
||||
Username string // PAT 时填,否则空
|
||||
Secret []byte // PAT: token 字节;SSH: PEM-encoded private key;None: nil
|
||||
}
|
||||
|
||||
// credentialEnv 把 cred 物化为临时文件 + env KEY=VALUE 切片,并返回 cleanup 闭包。
|
||||
// tmpDir 必须存在且可写;调用方在 git 命令完成后必须 defer cleanup()。
|
||||
//
|
||||
// nil cred 等价于 Kind==CredentialNone:返回 nil env、nil cleanup。
|
||||
func credentialEnv(tmpDir string, cred *Credential) (env []string, cleanup func(), err error) {
|
||||
if cred == nil || cred.Kind == CredentialNone {
|
||||
return nil, func() {}, nil
|
||||
}
|
||||
switch cred.Kind {
|
||||
case CredentialPAT:
|
||||
return patEnv(tmpDir, cred)
|
||||
case CredentialSSHKey:
|
||||
return sshKeyEnv(tmpDir, cred)
|
||||
default:
|
||||
return nil, func() {}, fmt.Errorf("unknown credential kind %q", cred.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),脚本读 GIT_PAT_TOKEN 输出。
|
||||
// git 在询问 username/password 时会调 GIT_ASKPASS 两次,第一次问 username,第二次问 password;
|
||||
// 我们的脚本忽略提示参数 $1,无论谁问都先返回 username 再返回 token。
|
||||
//
|
||||
// Windows note: cmd.exe 的 .bat 没有 stderr 隔离风险(脚本只 echo 一行)。
|
||||
func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
username := cred.Username
|
||||
if username == "" {
|
||||
// GitHub PAT 习惯 username='git';GitLab/Gitea 类似
|
||||
username = "git"
|
||||
}
|
||||
suffix := ".sh"
|
||||
body := patScriptShBody()
|
||||
mode := os.FileMode(0o700)
|
||||
if runtime.GOOS == "windows" {
|
||||
suffix = ".bat"
|
||||
body = patScriptBatBody()
|
||||
mode = 0o755
|
||||
}
|
||||
f, err := os.CreateTemp(tmpDir, "askpass-*"+suffix)
|
||||
if err != nil {
|
||||
return nil, func() {}, fmt.Errorf("create askpass: %w", err)
|
||||
}
|
||||
if _, err := f.WriteString(body); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, func() {}, fmt.Errorf("write askpass: %w", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if err := os.Chmod(f.Name(), mode); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, func() {}, fmt.Errorf("chmod askpass: %w", err)
|
||||
}
|
||||
env := []string{
|
||||
"GIT_ASKPASS=" + f.Name(),
|
||||
"GIT_PAT_USERNAME=" + username,
|
||||
"GIT_PAT_TOKEN=" + string(cred.Secret),
|
||||
// GIT_TERMINAL_PROMPT=0 防止 askpass 失效时 git 退回交互
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
}
|
||||
cleanup := func() { _ = os.Remove(f.Name()) }
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
func patScriptShBody() string {
|
||||
return `#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$GIT_PAT_USERNAME" ;;
|
||||
*) printf '%s' "$GIT_PAT_TOKEN" ;;
|
||||
esac
|
||||
`
|
||||
}
|
||||
|
||||
func patScriptBatBody() string {
|
||||
return "@echo off\r\n" +
|
||||
"echo %1 | findstr /i Username >NUL\r\n" +
|
||||
"if %ERRORLEVEL%==0 (echo %GIT_PAT_USERNAME%) else (echo %GIT_PAT_TOKEN%)\r\n"
|
||||
}
|
||||
|
||||
// sshKeyEnv 写临时密钥 + 空 known_hosts,构造 GIT_SSH_COMMAND。
|
||||
//
|
||||
// Linux: chmod 0600 已通过 os.WriteFile mode 0600 实现
|
||||
// Windows: 通过 icacls 限权(OpenSSH for Windows 检查文件 ACL)
|
||||
func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
|
||||
keyFile, err := os.CreateTemp(tmpDir, "ssh-key-*")
|
||||
if err != nil {
|
||||
return nil, func() {}, fmt.Errorf("create ssh key tmp: %w", err)
|
||||
}
|
||||
if _, err := keyFile.Write(cred.Secret); err != nil {
|
||||
_ = keyFile.Close()
|
||||
_ = os.Remove(keyFile.Name())
|
||||
return nil, func() {}, fmt.Errorf("write ssh key: %w", err)
|
||||
}
|
||||
_ = keyFile.Close()
|
||||
if err := os.Chmod(keyFile.Name(), 0o600); err != nil {
|
||||
_ = os.Remove(keyFile.Name())
|
||||
return nil, func() {}, fmt.Errorf("chmod ssh key: %w", err)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := windowsRestrictACL(keyFile.Name()); err != nil {
|
||||
_ = os.Remove(keyFile.Name())
|
||||
return nil, func() {}, fmt.Errorf("icacls ssh key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
khFile, err := os.CreateTemp(tmpDir, "known-hosts-*")
|
||||
if err != nil {
|
||||
_ = os.Remove(keyFile.Name())
|
||||
return nil, func() {}, fmt.Errorf("create known_hosts tmp: %w", err)
|
||||
}
|
||||
_ = khFile.Close()
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
"ssh -i %q -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q",
|
||||
keyFile.Name(), khFile.Name(),
|
||||
)
|
||||
env := []string{"GIT_SSH_COMMAND=" + cmd}
|
||||
cleanup := func() {
|
||||
_ = os.Remove(keyFile.Name())
|
||||
_ = os.Remove(khFile.Name())
|
||||
}
|
||||
return env, cleanup, nil
|
||||
}
|
||||
|
||||
// windowsRestrictACL 通过 icacls 把文件 ACL 限制为当前用户独占读取。
|
||||
// OpenSSH 否则会拒绝使用密钥("Permissions for 'xxx' are too open")。
|
||||
func windowsRestrictACL(path string) error {
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
user := os.Getenv("USERNAME")
|
||||
if user == "" {
|
||||
return errors.New("USERNAME env empty on windows")
|
||||
}
|
||||
out, err := runWindowsTool("icacls", path, "/inheritance:r", "/grant:r", user+":(R)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("icacls failed: %w (%s)", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runWindowsTool 把 icacls 调用单独抽出便于测试 stub。
|
||||
var runWindowsTool = func(name string, args ...string) (string, error) {
|
||||
return runExec(name, args...)
|
||||
}
|
||||
|
||||
// 通过 filepath.Join 引用避免 import 被 lint 抹掉
|
||||
var _ = filepath.Join
|
||||
|
||||
// runExec 执行外部小工具(icacls 等),返回 stdout+stderr 与错误。
|
||||
// 不复用 DefaultRunner.exec 以避免对 Config 的循环依赖。
|
||||
func runExec(name string, args ...string) (string, error) {
|
||||
out, err := osExec(name, args...)
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// osExec 是 os/exec.Command 的薄包装,暴露成变量便于测试 stub。
|
||||
var osExec = func(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).CombinedOutput()
|
||||
}
|
||||
Reference in New Issue
Block a user