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