git 逻辑

This commit is contained in:
2026-06-10 15:30:52 +08:00
parent 41f2a84979
commit 023ab2f30e
17 changed files with 221 additions and 64 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /out/server
# ─── Stage 3: runtime ─── # ─── Stage 3: runtime ───
FROM registry.jerryyan.top/library/alpine:3.20 FROM registry.jerryyan.top/library/alpine:3.20
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata && \ && apk add --no-cache ca-certificates tzdata git openssh-client && \
addgroup -S app && adduser -S app -G app addgroup -S app && adduser -S app -G app
WORKDIR /app WORKDIR /app
COPY --from=go /out/server /app/server COPY --from=go /out/server /app/server
+5 -1
View File
@@ -803,7 +803,11 @@ func (a workspaceFetcherAdapter) Fetch(ctx context.Context, mainPath string, wsI
if err != nil { if err != nil {
return err return err
} }
return a.gitr.Fetch(ctx, mainPath, cred) // 后台 fetch 成功后与手动 Sync 一致地把 main 工作区快进到远端
if err := a.gitr.Fetch(ctx, mainPath, cred); err != nil {
return err
}
return a.gitr.MergeFFOnly(ctx, mainPath)
} }
// projectLookup is the minimal interface needed by worktreePruneAdapter to // projectLookup is the minimal interface needed by worktreePruneAdapter to
+4 -2
View File
@@ -5,7 +5,9 @@ import (
"fmt" "fmt"
) )
// Clone 把 remoteURL 上的 branch 浅克隆到 dst。dst 不能预先存在。 // Clone 把 remoteURL 克隆到 dst(初始 checkout 指定 branch)。dst 不能预先存在。
// 不加 --single-branch:保留全部远端分支,使后续 fetch --all 与基于其他分支建
// worktree 可行。
func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error { func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string, cred *Credential) error {
ctx, cancel := withCtx(ctx, r.cfg.CloneTimeout) ctx, cancel := withCtx(ctx, r.cfg.CloneTimeout)
defer cancel() defer cancel()
@@ -14,7 +16,7 @@ func (r *DefaultRunner) Clone(ctx context.Context, dst, remoteURL, branch string
return fmt.Errorf("credential env: %w", err) return fmt.Errorf("credential env: %w", err)
} }
defer cleanup() defer cleanup()
args := []string{"clone", "--branch", branch, "--single-branch", remoteURL, dst} args := []string{"clone", "--branch", branch, remoteURL, dst}
_, _, err = r.exec(ctx, "", env, args...) _, _, err = r.exec(ctx, "", env, args...)
return err return err
} }
+59 -13
View File
@@ -1,6 +1,7 @@
package git package git
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -44,11 +45,13 @@ func credentialEnv(tmpDir string, cred *Credential) (env []string, cleanup func(
} }
} }
// patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),脚本读 GIT_PAT_TOKEN 输出。 // patEnv 创建 askpass 脚本(Linux .sh / Windows .bat),并把 username 与 token
// git 在询问 username/password 时会调 GIT_ASKPASS 两次,第一次问 username,第二次问 password; // 各写入一个 0600 临时文件,脚本按提示前缀(git 固定以 "Username for"/"Password for"
// 我们的脚本忽略提示参数 $1,无论谁问都先返回 username 再返回 token。 // 开头)路由:命中 Username 输出用户名文件,否则输出 token 文件
// //
// Windows note: cmd.exe 的 .bat 没有 stderr 隔离风险(脚本只 echo 一行)。 // 用文件 + type/cat 输出内容而非 echo env,规避 token 含 & | < > " 等特殊字符时的
// 命令注入/认证失败,也避免 token 经 env 泄漏给所有子进程。git 会自行裁掉输出末尾
// 的 CR/LF。
func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) { func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
username := cred.Username username := cred.Username
if username == "" { if username == "" {
@@ -77,30 +80,66 @@ func patEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
_ = os.Remove(f.Name()) _ = os.Remove(f.Name())
return nil, func() {}, fmt.Errorf("chmod askpass: %w", err) return nil, func() {}, fmt.Errorf("chmod askpass: %w", err)
} }
userFile, err := writeSecretFile(tmpDir, "askpass-user-*", []byte(username))
if err != nil {
_ = os.Remove(f.Name())
return nil, func() {}, fmt.Errorf("write username file: %w", err)
}
tokenFile, err := writeSecretFile(tmpDir, "askpass-token-*", cred.Secret)
if err != nil {
_ = os.Remove(f.Name())
_ = os.Remove(userFile)
return nil, func() {}, fmt.Errorf("write token file: %w", err)
}
env := []string{ env := []string{
"GIT_ASKPASS=" + f.Name(), "GIT_ASKPASS=" + f.Name(),
"GIT_PAT_USERNAME=" + username, "GIT_PAT_USERNAME_FILE=" + userFile,
"GIT_PAT_TOKEN=" + string(cred.Secret), "GIT_PAT_TOKEN_FILE=" + tokenFile,
// GIT_TERMINAL_PROMPT=0 防止 askpass 失效时 git 退回交互 // GIT_TERMINAL_PROMPT=0 防止 askpass 失效时 git 退回交互
"GIT_TERMINAL_PROMPT=0", "GIT_TERMINAL_PROMPT=0",
} }
cleanup := func() { _ = os.Remove(f.Name()) } cleanup := func() {
_ = os.Remove(f.Name())
_ = os.Remove(userFile)
_ = os.Remove(tokenFile)
}
return env, cleanup, nil return env, cleanup, nil
} }
// writeSecretFile 把 data 写入 tmpDir 下的 0600 临时文件,返回路径。
func writeSecretFile(tmpDir, pattern string, data []byte) (string, error) {
f, err := os.CreateTemp(tmpDir, pattern)
if err != nil {
return "", err
}
if _, err := f.Write(data); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
_ = f.Close()
if err := os.Chmod(f.Name(), 0o600); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
func patScriptShBody() string { func patScriptShBody() string {
return `#!/bin/sh return `#!/bin/sh
case "$1" in case "$1" in
*Username*) printf '%s' "$GIT_PAT_USERNAME" ;; Username*) cat "$GIT_PAT_USERNAME_FILE" ;;
*) printf '%s' "$GIT_PAT_TOKEN" ;; *) cat "$GIT_PAT_TOKEN_FILE" ;;
esac esac
` `
} }
func patScriptBatBody() string { func patScriptBatBody() string {
return "@echo off\r\n" + return "@echo off\r\n" +
"echo %1 | findstr /i Username >NUL\r\n" + "echo %1 | findstr /i /c:\"Username for\" >NUL\r\n" +
"if %ERRORLEVEL%==0 (echo %GIT_PAT_USERNAME%) else (echo %GIT_PAT_TOKEN%)\r\n" "if %ERRORLEVEL%==0 (type \"%GIT_PAT_USERNAME_FILE%\") else (type \"%GIT_PAT_TOKEN_FILE%\")\r\n"
} }
// sshKeyEnv 写临时密钥 + 空 known_hosts,构造 GIT_SSH_COMMAND。 // sshKeyEnv 写临时密钥 + 空 known_hosts,构造 GIT_SSH_COMMAND。
@@ -112,7 +151,12 @@ func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
if err != nil { if err != nil {
return nil, func() {}, fmt.Errorf("create ssh key tmp: %w", err) return nil, func() {}, fmt.Errorf("create ssh key tmp: %w", err)
} }
if _, err := keyFile.Write(cred.Secret); err != nil { // 规整换行:\r\n 与单独 \r 统一为 \n,并确保末尾有且仅有一个 \n。
// OpenSSH 新格式 / ED25519 密钥缺尾换行会报 invalid format。不改 cred.Secret 本身。
key := bytes.ReplaceAll(cred.Secret, []byte("\r\n"), []byte("\n"))
key = bytes.ReplaceAll(key, []byte("\r"), []byte("\n"))
key = append(bytes.TrimRight(key, "\n"), '\n')
if _, err := keyFile.Write(key); err != nil {
_ = keyFile.Close() _ = keyFile.Close()
_ = os.Remove(keyFile.Name()) _ = os.Remove(keyFile.Name())
return nil, func() {}, fmt.Errorf("write ssh key: %w", err) return nil, func() {}, fmt.Errorf("write ssh key: %w", err)
@@ -136,8 +180,10 @@ func sshKeyEnv(tmpDir string, cred *Credential) ([]string, func(), error) {
} }
_ = khFile.Close() _ = khFile.Close()
// BatchMode=yes:带 passphrase 私钥或任何交互需求立即失败并给出明确 stderr,
// 而非挂起。
cmd := fmt.Sprintf( cmd := fmt.Sprintf(
"ssh -i %q -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q", "ssh -i %q -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%q",
keyFile.Name(), khFile.Name(), keyFile.Name(), khFile.Name(),
) )
env := []string{"GIT_SSH_COMMAND=" + cmd} env := []string{"GIT_SSH_COMMAND=" + cmd}
+23 -1
View File
@@ -3,6 +3,8 @@ package git
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/url"
"strings"
) )
// GitError 是 git 子进程返回非零或超时时的统一错误形式。Stderr 已截断 ≤2000 字符; // GitError 是 git 子进程返回非零或超时时的统一错误形式。Stderr 已截断 ≤2000 字符;
@@ -15,7 +17,27 @@ type GitError struct {
} }
func (e *GitError) Error() string { func (e *GitError) Error() string {
return fmt.Sprintf("git %v: exit %d: %s", e.Args, e.ExitCode, e.Stderr) // 对 Args 中形如 scheme://user:pass@host 的 URL 脱敏,避免 PAT 明文
// 写进 last_sync_error / audit。
redacted := make([]string, len(e.Args))
for i, a := range e.Args {
redacted[i] = redactURLUserinfo(a)
}
return fmt.Sprintf("git %v: exit %d: %s", redacted, e.ExitCode, e.Stderr)
}
// redactURLUserinfo 剥离形如 scheme://user[:pass]@host 中的 userinfo,
// 输出 scheme://host...。非 URL 字符串或解析失败时原样返回。
func redactURLUserinfo(s string) string {
if !strings.Contains(s, "://") {
return s
}
u, err := url.Parse(s)
if err != nil || u.User == nil {
return s
}
u.User = nil
return u.String()
} }
func (e *GitError) Unwrap() error { return e.Cause } func (e *GitError) Unwrap() error { return e.Cause }
+13
View File
@@ -0,0 +1,13 @@
package git
import "context"
// MergeFFOnly 在 dir 执行 git merge --ff-only(无参数,默认合并当前分支的
// @{upstream})。fetch 后用它把当前分支快进到 @{upstream};当分支分叉或工作区
// 有冲突而无法 fast-forward 时返回错误,由上层处理。
func (r *DefaultRunner) MergeFFOnly(ctx context.Context, dir string) error {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel()
_, _, err := r.exec(ctx, dir, nil, "merge", "--ff-only")
return err
}
+12 -3
View File
@@ -26,7 +26,8 @@ type Runner interface {
Push(ctx context.Context, dir, branch string, cred *Credential) error Push(ctx context.Context, dir, branch string, cred *Credential) error
Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error) Commit(ctx context.Context, dir, message string, addAll bool) (sha string, err error)
Status(ctx context.Context, dir string) ([]FileStatus, error) Status(ctx context.Context, dir string) ([]FileStatus, error)
WorktreeAdd(ctx context.Context, dir, branch, path string) error MergeFFOnly(ctx context.Context, dir string) error
WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error
WorktreeRemove(ctx context.Context, dir, path string) error WorktreeRemove(ctx context.Context, dir, path string) error
WorktreeList(ctx context.Context, dir string) ([]Worktree, error) WorktreeList(ctx context.Context, dir string) ([]Worktree, error)
} }
@@ -103,11 +104,19 @@ func NewDefaultRunner(cfg Config) *DefaultRunner {
// //
// 返回 stdout/stderr 字节切片与错误。错误为 GitError 时携带 stderr 与 exit code。 // 返回 stdout/stderr 字节切片与错误。错误为 GitError 时携带 stderr 与 exit code。
func (r *DefaultRunner) exec(ctx context.Context, cwd string, env []string, args ...string) (stdout, stderr []byte, err error) { func (r *DefaultRunner) exec(ctx context.Context, cwd string, env []string, args ...string) (stdout, stderr []byte, err error) {
cmd := exec.CommandContext(ctx, r.cfg.Binary, args...) // 统一前置 `-c credential.helper=` 清空 helper 链,绕过 Git for Windows
// 默认 GCM 抢占 PAT 的问题。
fullArgs := append([]string{"-c", "credential.helper="}, args...)
cmd := exec.CommandContext(ctx, r.cfg.Binary, fullArgs...)
if cwd != "" { if cwd != "" {
cmd.Dir = cwd cmd.Dir = cwd
} }
cmd.Env = append(os.Environ(), env...) // base env 总是禁交互;调用方 env 追加在后可覆盖。
baseEnv := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never")
cmd.Env = append(baseEnv, env...)
// WaitDelay:超时 kill git 后,孤儿 ssh/git-remote-https 子进程可能持有
// stderr pipe 写端导致 cmd.Run() 无限阻塞,给一个上限强制收尾。
cmd.WaitDelay = 10 * time.Second
var so, se bytes.Buffer var so, se bytes.Buffer
cmd.Stdout = &so cmd.Stdout = &so
cmd.Stderr = &se cmd.Stderr = &se
+26 -2
View File
@@ -3,6 +3,7 @@ package git
import ( import (
"context" "context"
"errors" "errors"
"os"
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
@@ -11,6 +12,17 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// envValue 从 "K=V" 形式的 env 列表中取出 key 对应的 value。
func envValue(env []string, key string) string {
prefix := key + "="
for _, e := range env {
if strings.HasPrefix(e, prefix) {
return strings.TrimPrefix(e, prefix)
}
}
return ""
}
func TestDefaultRunner_exec_BinaryNotFound(t *testing.T) { func TestDefaultRunner_exec_BinaryNotFound(t *testing.T) {
t.Parallel() t.Parallel()
r := NewDefaultRunner(Config{Binary: "this-is-not-a-real-binary-xyz"}) r := NewDefaultRunner(Config{Binary: "this-is-not-a-real-binary-xyz"})
@@ -75,9 +87,21 @@ func TestCredentialEnv_PAT(t *testing.T) {
defer cleanup() defer cleanup()
joined := strings.Join(env, "\n") joined := strings.Join(env, "\n")
require.Contains(t, joined, "GIT_ASKPASS=") require.Contains(t, joined, "GIT_ASKPASS=")
require.Contains(t, joined, "GIT_PAT_USERNAME=alice")
require.Contains(t, joined, "GIT_PAT_TOKEN=tok")
require.Contains(t, joined, "GIT_TERMINAL_PROMPT=0") require.Contains(t, joined, "GIT_TERMINAL_PROMPT=0")
// 凭据通过 0600 临时文件下发(避免出现在进程 env 中),env 仅暴露文件路径,
// 文件内容须等于用户名/令牌原文。
require.Contains(t, joined, "GIT_PAT_USERNAME_FILE=")
require.Contains(t, joined, "GIT_PAT_TOKEN_FILE=")
userFile := envValue(env, "GIT_PAT_USERNAME_FILE")
tokenFile := envValue(env, "GIT_PAT_TOKEN_FILE")
require.NotEmpty(t, userFile)
require.NotEmpty(t, tokenFile)
userData, err := os.ReadFile(userFile)
require.NoError(t, err)
require.Equal(t, "alice", string(userData))
tokenData, err := os.ReadFile(tokenFile)
require.NoError(t, err)
require.Equal(t, "tok", string(tokenData))
} }
func TestCredentialEnv_SSHKey(t *testing.T) { func TestCredentialEnv_SSHKey(t *testing.T) {
+8 -5
View File
@@ -10,17 +10,20 @@ import (
// WorktreeAdd 在 dir(main worktree)创建一个支线 worktree。 // WorktreeAdd 在 dir(main worktree)创建一个支线 worktree。
// //
// path: 绝对路径,由 workspace 包计算 // path: 绝对路径,由 workspace 包计算
// branch: 用 -B 强制创建/重置 — 已存在的分支会被 reset 到 origin/HEAD // branch: 用 -B 强制创建/重置 — 已存在的分支会被重置(本地未推送
// (本地未推送 commit 会丢失,可从 reflog 找回);--track 让分支 // commit 会丢失,可从 reflog 找回)
// 默认 track origin/HEAD(通常是 main),首次 push 时由 service // startPoint: 为空时分支基于当前 HEAD;非空时分支基于该 start-point,
// 层 set-upstream 到对应远端分支 // 且 --track 会把 upstream 指向它
// //
// 失败时如果 stderr 含 "already exists" / "is already used by worktree", // 失败时如果 stderr 含 "already exists" / "is already used by worktree",
// errors.IsConflict 返回 true,调用方应映射为 WORKTREE_BRANCH_CONFLICT。 // errors.IsConflict 返回 true,调用方应映射为 WORKTREE_BRANCH_CONFLICT。
func (r *DefaultRunner) WorktreeAdd(ctx context.Context, dir, branch, path string) error { func (r *DefaultRunner) WorktreeAdd(ctx context.Context, dir, branch, path, startPoint string) error {
ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout)
defer cancel() defer cancel()
args := []string{"worktree", "add", "--track", "-B", branch, path} args := []string{"worktree", "add", "--track", "-B", branch, path}
if startPoint != "" {
args = append(args, startPoint)
}
_, _, err := r.exec(ctx, dir, nil, args...) _, _, err := r.exec(ctx, dir, nil, args...)
return err return err
} }
+1 -1
View File
@@ -20,7 +20,7 @@ func TestWorktree_AddListRemove(t *testing.T) {
require.NoError(t, r.Clone(ctx, main, bare, "main", nil)) require.NoError(t, r.Clone(ctx, main, bare, "main", nil))
feat := filepath.Join(filepath.Dir(main), "feat-x") feat := filepath.Join(filepath.Dir(main), "feat-x")
require.NoError(t, r.WorktreeAdd(ctx, main, "feat-x", feat)) require.NoError(t, r.WorktreeAdd(ctx, main, "feat-x", feat, ""))
list, err := r.WorktreeList(ctx, main) list, err := r.WorktreeList(ctx, main)
require.NoError(t, err) require.NoError(t, err)
+5 -3
View File
@@ -82,13 +82,15 @@ func (r *WorkspaceFetch) fetchOne(ctx context.Context, t WorkspaceFetchTarget) {
// 别的 runner / 手工同步抢先了 // 别的 runner / 手工同步抢先了
return return
} }
// 即便 runner ctx 因关停被取消,fetch 结果状态仍需可靠落库,避免行永久残留 syncing。
writeCtx := context.WithoutCancel(ctx)
if fetchErr := r.gitr.Fetch(ctx, t.MainPath, t.ID); fetchErr != nil { if fetchErr := r.gitr.Fetch(ctx, t.MainPath, t.ID); fetchErr != nil {
msg := truncate(fetchErr.Error(), 2000) msg := truncate(fetchErr.Error(), 2000)
if err := r.repo.MarkFetchResult(ctx, t.ID, false, msg); err != nil { if err := r.repo.MarkFetchResult(writeCtx, t.ID, false, msg); err != nil {
r.log.Error("workspace_fetch.mark_fail", "workspace_id", t.ID, "err", err.Error()) r.log.Error("workspace_fetch.mark_fail", "workspace_id", t.ID, "err", err.Error())
return return
} }
if derr := r.disp.Dispatch(ctx, notify.Message{ if derr := r.disp.Dispatch(writeCtx, notify.Message{
UserID: t.OwnerID, UserID: t.OwnerID,
Topic: "workspace.sync_failed", Topic: "workspace.sync_failed",
Severity: notify.SeverityError, Severity: notify.SeverityError,
@@ -106,7 +108,7 @@ func (r *WorkspaceFetch) fetchOne(ctx context.Context, t WorkspaceFetchTarget) {
r.log.Warn("workspace_fetch.failed", "workspace_id", t.ID, "err", msg) r.log.Warn("workspace_fetch.failed", "workspace_id", t.ID, "err", msg)
return return
} }
if err := r.repo.MarkFetchResult(ctx, t.ID, true, ""); err != nil { if err := r.repo.MarkFetchResult(writeCtx, t.ID, true, ""); err != nil {
r.log.Error("workspace_fetch.mark_ok", "workspace_id", t.ID, "err", err.Error()) r.log.Error("workspace_fetch.mark_ok", "workspace_id", t.ID, "err", err.Error())
} }
} }
+8 -5
View File
@@ -63,22 +63,25 @@ func (r *CloneRunner) run(ctx context.Context, wsID uuid.UUID) {
r.log.Error("clone runner: get workspace", "ws_id", wsID, "err", err.Error()) r.log.Error("clone runner: get workspace", "ws_id", wsID, "err", err.Error())
return return
} }
// 状态/审计写操作与 clone 的超时 ctx 解耦:clone 超时后 ctx 已 DeadlineExceeded,
// 仍需把状态写成 error,否则永久卡在 cloning。
writeCtx := context.WithoutCancel(ctx)
cred, err := r.resolveCred(ctx, wsID) cred, err := r.resolveCred(ctx, wsID)
if err != nil { if err != nil {
r.markError(ctx, wsID, "resolve credential: "+err.Error()) r.markError(writeCtx, wsID, "resolve credential: "+err.Error())
return return
} }
if cloneErr := r.gitr.Clone(ctx, ws.MainPath, ws.GitRemoteURL, ws.DefaultBranch, cred); cloneErr != nil { if cloneErr := r.gitr.Clone(ctx, ws.MainPath, ws.GitRemoteURL, ws.DefaultBranch, cred); cloneErr != nil {
r.markError(ctx, wsID, summarizeErr(cloneErr)) r.markError(writeCtx, wsID, summarizeErr(cloneErr))
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}}) _ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_failed", TargetType: "workspace", TargetID: wsID.String(), Metadata: map[string]any{"err": summarizeErr(cloneErr)}})
return return
} }
now := time.Now().UTC() now := time.Now().UTC()
if _, err := r.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, ""); err != nil { if _, err := r.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, ""); err != nil {
r.log.Error("clone runner: update sync status idle", "ws_id", wsID, "err", err.Error()) r.log.Error("clone runner: update sync status idle", "ws_id", wsID, "err", err.Error())
return return
} }
_ = r.rec.Record(ctx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()}) _ = r.rec.Record(writeCtx, audit.Entry{Action: "workspace.clone_ok", TargetType: "workspace", TargetID: wsID.String()})
} }
func (r *CloneRunner) markError(ctx context.Context, wsID uuid.UUID, msg string) { func (r *CloneRunner) markError(ctx context.Context, wsID uuid.UUID, msg string) {
+17 -3
View File
@@ -37,13 +37,27 @@ func ValidateBranch(b string) error {
return nil return nil
} }
// ValidateRemoteURL 检查 scheme 白名单。 // ValidateRemoteURL 检查 scheme 白名单,并拒绝 scheme:// 形式中内嵌的 userinfo(凭据)
func ValidateRemoteURL(u string) error { func ValidateRemoteURL(u string) error {
switch { switch {
case strings.HasPrefix(u, "https://"), case strings.HasPrefix(u, "https://"),
strings.HasPrefix(u, "http://"), strings.HasPrefix(u, "http://"),
strings.HasPrefix(u, "ssh://"), strings.HasPrefix(u, "ssh://"):
strings.HasPrefix(u, "git@"): // 拒绝 scheme://user:pass@host 形式的内嵌凭据:内嵌凭据会绕过本系统凭据
// 存储并短路数据库 PAT。注意 ssh://git@host 这类裸用户名(无 ":" 密码)是
// 合法 SSH 形式,仅当 userinfo 中带 ":" 密码时才判定为内嵌凭据。
rest := u[strings.Index(u, "://")+len("://"):]
authority := rest
if i := strings.IndexByte(rest, '/'); i >= 0 {
authority = rest[:i]
}
if at := strings.IndexByte(authority, '@'); at >= 0 {
if strings.ContainsRune(authority[:at], ':') {
return errors.New("git_remote_url must not contain embedded credentials")
}
}
return nil
case strings.HasPrefix(u, "git@"):
return nil return nil
} }
return errors.New("git_remote_url must be https/http/ssh/git@ scheme") return errors.New("git_remote_url must be https/http/ssh/git@ scheme")
+2
View File
@@ -44,6 +44,8 @@ func TestValidateRemoteURL(t *testing.T) {
require.NoError(t, ValidateRemoteURL("ssh://git@host/x.git")) require.NoError(t, ValidateRemoteURL("ssh://git@host/x.git"))
require.Error(t, ValidateRemoteURL("file:///etc/passwd")) require.Error(t, ValidateRemoteURL("file:///etc/passwd"))
require.Error(t, ValidateRemoteURL("ftp://x")) require.Error(t, ValidateRemoteURL("ftp://x"))
// 内嵌凭据(user:pass@)须被拒绝,避免绕过本系统凭据存储
require.Error(t, ValidateRemoteURL("https://user:pass@github.com/x/y.git"))
} }
func TestPaths(t *testing.T) { func TestPaths(t *testing.T) {
+30 -18
View File
@@ -212,31 +212,43 @@ func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) (
} else if !canWrite { } else if !canWrite {
return nil, errs.New(errs.CodeForbidden, "no write access") return nil, errs.New(errs.CodeForbidden, "no write access")
} }
if ws.SyncStatus == SyncStatusCloning || ws.SyncStatus == SyncStatusSyncing { // 先解密凭据,再抢锁置 syncing:避免置 syncing 后 load 失败留下残留。
return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress")
}
if _, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusSyncing, ws.LastSyncedAt, ""); err != nil {
return nil, err
}
cred, err := s.loadDecryptedCredential(ctx, wsID) cred, err := s.loadDecryptedCredential(ctx, wsID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout) // 原子 CAS 抢锁(WHERE sync_status IN ('idle','error')),消除"读后写"TOCTOU。
defer cancel() // 抢到才继续,否则说明已有 clone/sync 在进行中。
fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred) acquired, err := s.repo.MarkSyncing(ctx, wsID)
if fetchErr != nil {
errMsg := summarizeErr(fetchErr)
_, _ = s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
s.audit(ctx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed")
}
now := time.Now().UTC()
updated, err := s.repo.UpdateWorkspaceSyncStatus(ctx, wsID, SyncStatusIdle, &now, "")
if err != nil { if err != nil {
return nil, err return nil, err
} }
s.audit(ctx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil) if !acquired {
return nil, errs.New(errs.CodeWorkspaceSyncInProgress, "sync already in progress")
}
// 状态回写与请求生命周期解耦:客户端断开/请求超时也要能写回状态,避免永久卡 syncing。
writeCtx := context.WithoutCancel(ctx)
fetchCtx, cancel := context.WithTimeout(ctx, s.fetchTimout)
defer cancel()
if fetchErr := s.gitr.Fetch(fetchCtx, ws.MainPath, cred); fetchErr != nil {
errMsg := summarizeErr(fetchErr)
_, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
return nil, errs.Wrap(fetchErr, errs.CodeGitCmdFailed, "git fetch failed")
}
// fetch 只更新远端引用,需 ff-only merge 把 main 工作区当前分支快进到远端。
if mergeErr := s.gitr.MergeFFOnly(fetchCtx, ws.MainPath); mergeErr != nil {
errMsg := summarizeErr(mergeErr)
_, _ = s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusError, ws.LastSyncedAt, errMsg)
s.audit(writeCtx, &c.UserID, "workspace.sync_failed", "workspace", wsID.String(), map[string]any{"err": errMsg})
return nil, errs.Wrap(mergeErr, errs.CodeGitCmdFailed, "git merge --ff-only failed")
}
now := time.Now().UTC()
updated, err := s.repo.UpdateWorkspaceSyncStatus(writeCtx, wsID, SyncStatusIdle, &now, "")
if err != nil {
return nil, err
}
s.audit(writeCtx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil)
return updated, nil return updated, nil
} }
+2 -1
View File
@@ -205,7 +205,8 @@ func (f *fakeGit) Commit(_ context.Context, _, _ string, _ bool) (string, error)
return "deadbeef", nil return "deadbeef", nil
} }
func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil } func (f *fakeGit) Status(_ context.Context, _ string) ([]git.FileStatus, error) { return nil, nil }
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _ string) error { return nil } func (f *fakeGit) MergeFFOnly(_ context.Context, _ string) error { return nil }
func (f *fakeGit) WorktreeAdd(_ context.Context, _, _, _, _ string) error { return nil }
func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil } func (f *fakeGit) WorktreeRemove(_ context.Context, _, _ string) error { return nil }
func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) { func (f *fakeGit) WorktreeList(_ context.Context, _ string) ([]git.Worktree, error) {
return nil, nil return nil, nil
+1 -1
View File
@@ -46,7 +46,7 @@ func (s *worktreeService) Create(ctx context.Context, c Caller, wsID uuid.UUID,
return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid base branch") return nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid base branch")
} }
wtPath := WorktreePath(s.dataDir, wsID, in.Branch) wtPath := WorktreePath(s.dataDir, wsID, in.Branch)
if err := s.gitr.WorktreeAdd(ctx, ws.MainPath, in.Branch, wtPath); err != nil { if err := s.gitr.WorktreeAdd(ctx, ws.MainPath, in.Branch, wtPath, "origin/"+base); err != nil {
if git.IsConflict(err) { if git.IsConflict(err) {
return nil, errs.Wrap(err, errs.CodeWorktreeBranchConflict, "branch already used") return nil, errs.Wrap(err, errs.CodeWorktreeBranchConflict, "branch already used")
} }