Files
kandev/apps/backend/internal/repoclone/clone.go
T
2026-09-13 21:57:54 +01:00

1031 lines
35 KiB
Go

// Package repoclone handles automatic cloning and fetching of git repositories.
package repoclone
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/subproc"
)
const (
gitNoTags = "--no-tags"
githubProvider = "github"
githubHTTPSOrigin = "https://github.com"
gitlabProvider = "gitlab"
protocolHTTP = "http"
gitHubCredentialEnv = "KANDEV_REPOCLONE_GITHUB_TOKEN"
gitHubCredentialUserEnv = "KANDEV_REPOCLONE_GITHUB_USERNAME"
gitCredentialUsernameFileEnv = "KANDEV_GIT_USERNAME_FILE"
gitCredentialPasswordFileEnv = "KANDEV_GIT_PASSWORD_FILE"
gitCredentialMaxBytes = 16 * 1024
managedWorkspacesDir = "workspaces"
providerCloneDir = "_providers"
maxGitDiagnosticBytes = 4096
gitFetchTimeout = 30 * time.Second
gitCloneTimeout = 5 * time.Minute
)
var (
ErrWorkspaceCredentialUnavailable = errors.New("workspace Git credential is unavailable")
ErrRepositoryOwnershipMismatch = errors.New("managed repository ownership mismatch")
ErrManagedCloneOriginMismatch = errors.New("managed repository origin does not match requested clone URL")
gitURLUserInfoPattern = regexp.MustCompile(`(?i)(https?://)[^/\s@]+@`)
gitCredentialPattern = regexp.MustCompile(`(?i)\b(password|token|secret|authorization)(\s*[:=]\s*)[^\r\n]+`)
)
// Config holds configuration for the repository cloner.
type Config struct {
// BasePath is the base directory for cloned repos.
// Supports ~ expansion for home directory.
// Default: ~/.kandev/repos
BasePath string `mapstructure:"basePath"`
}
// Cloner handles git clone and fetch operations.
type Cloner struct {
config Config
protocolResolver GitProtocolResolver
logger *logger.Logger
credentials GitCredentialProvider
repoMus sync.Map
}
// GitCredentialProvider resolves the workspace automation identity selected
// for Git transport. Implementations must never return a personal credential.
type GitCredentialProvider interface {
ResolveGitCredential(context.Context, GitCredentialRequest) (username, password string, err error)
}
// GitCredentialRequest is the exact credential-free repository identity a
// host materialization is about to clone. ProviderHost and CloneURL remain
// separate so resolvers can enforce same-origin routing before returning a
// transient secret.
type GitCredentialRequest struct {
WorkspaceID string
TaskID string
SessionID string
RepositoryID string
Provider string
ProviderHost string
ProviderScope string
ProviderRepositoryID string
CloneURL string
Owner string
Name string
CheckoutBranch string
PRNumber int
}
type staticGitProtocolResolver string
func (r staticGitProtocolResolver) ResolveGitProtocol(context.Context, string) string {
return string(r)
}
// NewCloner creates a new Cloner with the given configuration.
func NewCloner(cfg Config, protocol string, dataDir string, log *logger.Logger) *Cloner {
return newCloner(cfg, staticGitProtocolResolver(protocol), dataDir, log)
}
// NewClonerWithProtocolResolver creates a Cloner that resolves Git protocols
// when protocol-aware clone URLs are built.
func NewClonerWithProtocolResolver(
cfg Config, resolver GitProtocolResolver, dataDir string, log *logger.Logger,
) *Cloner {
if resolver == nil {
resolver = NewGitProtocolResolver()
}
return newCloner(cfg, resolver, dataDir, log)
}
func newCloner(cfg Config, resolver GitProtocolResolver, dataDir string, log *logger.Logger) *Cloner {
if cfg.BasePath == "" && dataDir != "" {
cfg.BasePath = filepath.Join(dataDir, "repos")
}
return &Cloner{config: cfg, protocolResolver: resolver, logger: log}
}
// SetGitCredentialProvider configures workspace-scoped Git transport auth.
func (c *Cloner) SetGitCredentialProvider(provider GitCredentialProvider) {
c.credentials = provider
}
func (c *Cloner) repoMu(path string) *sync.Mutex {
mu, _ := c.repoMus.LoadOrStore(path, &sync.Mutex{})
return mu.(*sync.Mutex) //nolint:forcetypeassert // LoadOrStore always stores *sync.Mutex
}
// ExpandedBasePath returns the base path with ~ expanded to the user's home directory.
func (c *Cloner) ExpandedBasePath() (string, error) {
path := c.config.BasePath
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("get home directory: %w", err)
}
path = filepath.Join(home, path[2:])
}
return path, nil
}
// BuildCloneURL constructs a protocol-aware clone URL for a provider repository.
func (c *Cloner) BuildCloneURL(ctx context.Context, provider, owner, name string) (string, error) {
host, err := providerHost(provider)
if err != nil {
return "", err
}
return CloneURL(provider, owner, name, c.resolveProtocol(ctx, host))
}
// BuildCloneURLWithHost constructs a clone URL using a persisted provider origin.
func (c *Cloner) BuildCloneURLWithHost(
ctx context.Context, provider, host, owner, name string,
) (string, error) {
resolvedHost, _, err := normalizeGitProviderHost(host)
if err != nil {
return "", err
}
if resolvedHost == "" {
resolvedHost, err = providerHost(provider)
if err != nil {
return "", err
}
}
return CloneURLWithHost(provider, host, owner, name, c.resolveProtocol(ctx, resolvedHost))
}
func (c *Cloner) resolveProtocol(ctx context.Context, host string) string {
if c.protocolResolver == nil {
return ProtocolSSH
}
protocol := strings.ToLower(strings.TrimSpace(c.protocolResolver.ResolveGitProtocol(ctx, host)))
if protocol == ProtocolHTTPS {
return ProtocolHTTPS
}
return ProtocolSSH
}
// RepoPath returns the legacy owner/name clone path.
func (c *Cloner) RepoPath(owner, name string) (string, error) {
basePath, err := c.ExpandedBasePath()
if err != nil {
return "", err
}
if err := validateOwnerPath(owner); err != nil {
return "", err
}
if err := validatePathSegment(name); err != nil {
return "", err
}
return filepath.Join(basePath, filepath.FromSlash(owner), name), nil
}
// ProviderRepoPath returns the legacy provider/origin clone path.
func (c *Cloner) ProviderRepoPath(provider, providerHost, owner, name string) (string, error) {
basePath, err := c.ExpandedBasePath()
if err != nil {
return "", err
}
if err := validateOwnerPath(owner); err != nil {
return "", err
}
if err := validatePathSegment(name); err != nil {
return "", err
}
return filepath.Join(
basePath, providerCloneDir, clonePathSegment(provider),
providerHostPathSegment(providerHost), filepath.FromSlash(owner), name,
), nil
}
// WorkspaceRepoPath returns the isolated path for a default-origin provider repository.
func (c *Cloner) WorkspaceRepoPath(workspaceID, provider, owner, name string) (string, error) {
return c.WorkspaceProviderRepoPath(workspaceID, provider, "", owner, name)
}
// WorkspaceProviderRepoPath isolates managed clones by workspace, provider,
// and non-default provider origin.
func (c *Cloner) WorkspaceProviderRepoPath(
workspaceID, provider, providerHost, owner, name string,
) (string, error) {
return c.WorkspaceProviderRepositoryPath(workspaceID, provider, providerHost, "", "", owner, name)
}
// WorkspaceProviderRepositoryPath isolates managed clones using the provider's
// opaque connection scope and immutable repository ID. Legacy callers that do
// not yet carry both fields retain the origin/owner/name layout.
func (c *Cloner) WorkspaceProviderRepositoryPath(
workspaceID, provider, providerHost, providerScope, providerRepositoryID, owner, name string,
) (string, error) {
basePath, err := c.ExpandedBasePath()
if err != nil {
return "", err
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
provider = githubProvider
}
for _, segment := range []string{workspaceID, provider} {
if err := validatePathSegment(segment); err != nil {
return "", err
}
}
if providerScope != "" || providerRepositoryID != "" {
if strings.TrimSpace(providerScope) == "" || strings.TrimSpace(providerRepositoryID) == "" {
return "", errors.New("provider scope and repository ID must be supplied together")
}
return filepath.Join(
basePath, managedWorkspacesDir, workspaceID, provider, "_scopes",
stableIdentitySegment(providerScope), stableIdentitySegment(providerRepositoryID),
), nil
}
if err := validatePathSegment(name); err != nil {
return "", err
}
if err := validateOwnerPath(owner); err != nil {
return "", err
}
parts := []string{basePath, managedWorkspacesDir, workspaceID, provider}
if host := nonDefaultProviderHostSegment(provider, providerHost); host != "" {
parts = append(parts, host)
}
parts = append(parts, filepath.FromSlash(owner), name)
return filepath.Join(parts...), nil
}
func stableIdentitySegment(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func nonDefaultProviderHostSegment(provider, providerHost string) string {
parsedHost := strings.TrimSpace(providerHost)
if parsed, err := url.Parse(parsedHost); err == nil && parsed.Host != "" {
parsedHost = parsed.Host
}
parsedHost = strings.ToLower(strings.TrimSpace(parsedHost))
if parsedHost == "" || provider == githubProvider && parsedHost == "github.com" ||
provider == gitlabProvider && parsedHost == "gitlab.com" {
return ""
}
return clonePathSegment(parsedHost)
}
func providerHostPathSegment(providerHost string) string {
host := strings.TrimSpace(providerHost)
if parsed, err := url.Parse(host); err == nil && parsed.Host != "" {
host = parsed.Host
}
return clonePathSegment(host)
}
func clonePathSegment(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var result strings.Builder
result.Grow(len(value))
lastWasSeparator := false
for _, char := range value {
allowed := char >= 'a' && char <= 'z' || char >= '0' && char <= '9' || char == '.' || char == '_'
if allowed {
result.WriteRune(char)
lastWasSeparator = false
} else if !lastWasSeparator {
result.WriteByte('-')
lastWasSeparator = true
}
}
segment := strings.Trim(result.String(), "-.")
if segment == "" {
return "unknown"
}
return segment
}
func validatePathSegment(segment string) error {
if segment == "" || segment == "." || segment == ".." || filepath.Clean(segment) != segment ||
strings.ContainsAny(segment, `/\`) {
return fmt.Errorf("invalid managed clone path segment %q", segment)
}
return nil
}
func validateOwnerPath(owner string) error {
if strings.Contains(owner, `\`) {
return fmt.Errorf("invalid managed clone owner path %q", owner)
}
for _, segment := range strings.Split(owner, "/") {
if err := validatePathSegment(segment); err != nil {
return fmt.Errorf("invalid managed clone owner path %q: %w", owner, err)
}
}
return nil
}
// ShouldRecloneForWorkspace reports whether a managed path is not isolated to the workspace.
func (c *Cloner) ShouldRecloneForWorkspace(workspaceID, path string) bool {
basePath, err := c.ExpandedBasePath()
if err != nil || path == "" {
return false
}
managed, err := pathWithin(basePath, path)
if err != nil || !managed {
return false
}
if err := validatePathSegment(workspaceID); err != nil {
return true
}
workspaceRoot := filepath.Join(basePath, managedWorkspacesDir, workspaceID)
isolated, isolatedErr := pathWithin(workspaceRoot, path)
return isolatedErr != nil || !isolated
}
func pathWithin(root, path string) (bool, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return false, err
}
absPath, err := filepath.Abs(path)
if err != nil {
return false, err
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false, err
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil
}
// EnsureCloned retains the legacy public-clone behavior for non-workspace callers.
func (c *Cloner) EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) {
return c.EnsureClonedWithAuth(ctx, cloneURL, owner, name, "", "")
}
// EnsureClonedWithAuth scopes an HTTPS credential to one exact provider origin.
func (c *Cloner) EnsureClonedWithAuth(
ctx context.Context, cloneURL, owner, name, credentialOrigin, token string,
) (string, error) {
targetPath, err := c.RepoPath(owner, name)
if err != nil {
return "", err
}
auth, err := credentialAuth(cloneURL, credentialOrigin, token)
if err != nil {
return "", err
}
return c.ensureClonedAtPath(ctx, cloneURL, targetPath, auth)
}
// EnsureClonedForProvider retains the legacy provider/origin clone layout.
func (c *Cloner) EnsureClonedForProvider(
ctx context.Context, cloneURL, provider, providerHost, owner, name, credentialOrigin, token string,
) (string, error) {
targetPath, err := c.ProviderRepoPath(provider, providerHost, owner, name)
if err != nil {
return "", err
}
auth, err := credentialAuth(cloneURL, credentialOrigin, token)
if err != nil {
return "", err
}
return c.ensureClonedAtPath(ctx, cloneURL, targetPath, auth)
}
// EnsureWorkspaceCloned clones a repository into its workspace-isolated path.
func (c *Cloner) EnsureWorkspaceCloned(
ctx context.Context, workspaceID, provider, cloneURL, owner, name string,
) (string, error) {
return c.EnsureWorkspaceClonedForProvider(
ctx, workspaceID, cloneURL, provider, "", owner, name, "", "",
)
}
// EnsureWorkspaceClonedForProvider combines workspace isolation with exact
// provider-origin credential scoping.
func (c *Cloner) EnsureWorkspaceClonedForProvider(
ctx context.Context, workspaceID, cloneURL, provider, providerHost,
owner, name, credentialOrigin, token string,
) (string, error) {
return c.EnsureWorkspaceClonedWithCredentialRequest(ctx, GitCredentialRequest{
WorkspaceID: workspaceID, Provider: provider, ProviderHost: providerHost,
CloneURL: cloneURL, Owner: owner, Name: name,
}, credentialOrigin, token)
}
// EnsureWorkspaceClonedWithCredentialRequest preserves the exact host-derived
// task/session/repository scope while resolving a plugin provider credential.
func (c *Cloner) EnsureWorkspaceClonedWithCredentialRequest(
ctx context.Context, request GitCredentialRequest, credentialOrigin, token string,
) (string, error) {
targetPath, err := c.WorkspaceProviderRepositoryPath(
request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope,
request.ProviderRepositoryID, request.Owner, request.Name,
)
if err != nil {
return "", err
}
cloneURL, auth, err := c.workspaceCloneAuthRequest(ctx, request, credentialOrigin, token)
if err != nil {
return "", err
}
return c.ensureClonedAtPathWithOriginVerification(
ctx, cloneURL, targetPath, auth, request.ProviderScope != "" && request.ProviderRepositoryID != "",
)
}
// RefreshWorkspaceRepositoryWithCredentialRequest strictly refreshes one
// workspace-managed checkout using the same exact task/session/repository scope
// as its initial clone. Unlike the best-effort fetch used by Ensure*, this
// operation returns failures so callers can avoid falling through to an
// unauthenticated worktree fetch.
func (c *Cloner) RefreshWorkspaceRepositoryWithCredentialRequest(
ctx context.Context, request GitCredentialRequest, repositoryPath, credentialOrigin, token string,
) error {
targetPath, err := c.WorkspaceProviderRepositoryPath(
request.WorkspaceID, request.Provider, request.ProviderHost, request.ProviderScope,
request.ProviderRepositoryID, request.Owner, request.Name,
)
if err != nil {
return err
}
if !sameFilesystemPath(targetPath, repositoryPath) {
return errors.New("repository path does not match the scoped workspace checkout")
}
cloneURL, auth, err := c.workspaceCloneAuthRequest(ctx, request, credentialOrigin, token)
if err != nil {
return err
}
return c.refreshWorkspaceRepository(ctx, targetPath, cloneURL, auth, request.PRNumber)
}
// RefreshWorkspaceRepositoryWithBasicAuth strictly refreshes one existing
// workspace-managed checkout with the same basic-auth contract as cloning.
func (c *Cloner) RefreshWorkspaceRepositoryWithBasicAuth(
ctx context.Context, workspaceID, provider, providerHost,
cloneURL, owner, name, repositoryPath, username, password string,
) error {
targetPath, err := c.WorkspaceProviderRepoPath(workspaceID, provider, providerHost, owner, name)
if err != nil {
return err
}
if !sameFilesystemPath(targetPath, repositoryPath) {
return errors.New("repository path does not match the workspace checkout")
}
origin, err := gitCredentialOrigin(cloneURL)
if err != nil {
return err
}
return c.refreshWorkspaceRepository(ctx, targetPath, cloneURL, &cloneAuth{
origin: origin, username: username, password: password,
}, 0)
}
func (c *Cloner) refreshWorkspaceRepository(
ctx context.Context, targetPath, cloneURL string, auth *cloneAuth, prNumber int,
) error {
mu := c.repoMu(targetPath)
mu.Lock()
defer mu.Unlock()
if !gitCheckoutExists(targetPath) {
return errors.New("scoped workspace checkout is not a Git repository")
}
if err := c.setOriginURLLocked(ctx, targetPath, cloneURL); err != nil {
return err
}
runFetch := func(refspec string) error {
args := []string{"-C", targetPath, "fetch", "--prune", "--force", gitNoTags, "origin"}
if refspec != "" {
args = append(args, refspec)
}
if out, runErr := c.runConfiguredGitCombined(ctx, gitFetchTimeout, args,
func(cmd *exec.Cmd) (func(), error) { return configureGitCommand(cmd, auth) },
); runErr != nil {
return fmt.Errorf("refresh scoped workspace repository: %s: %w",
redactCloneOutput(string(out), authToken(auth)), runErr)
}
return nil
}
if err := runFetch(""); err != nil {
return err
}
if prNumber > 0 {
prRefspec := fmt.Sprintf("pull/%d/head:refs/kandev/pull/%d/head", prNumber, prNumber)
if err := runFetch(prRefspec); err != nil {
return fmt.Errorf("refresh scoped workspace pull request: %w", err)
}
}
return nil
}
func sameFilesystemPath(left, right string) bool {
leftAbs, leftErr := filepath.Abs(filepath.Clean(left))
rightAbs, rightErr := filepath.Abs(filepath.Clean(right))
return leftErr == nil && rightErr == nil && leftAbs == rightAbs
}
func gitCheckoutExists(path string) bool {
info, err := os.Stat(filepath.Join(path, ".git"))
return err == nil && (info.IsDir() || info.Mode().IsRegular())
}
// SetOriginURL updates a managed checkout's origin without exposing credentials.
func (c *Cloner) SetOriginURL(ctx context.Context, repositoryPath, originURL string) error {
if strings.TrimSpace(repositoryPath) == "" || strings.TrimSpace(originURL) == "" {
return errors.New("repository path and origin URL are required")
}
mu := c.repoMu(repositoryPath)
mu.Lock()
defer mu.Unlock()
return c.setOriginURLLocked(ctx, repositoryPath, originURL)
}
func (c *Cloner) setOriginURLLocked(ctx context.Context, repositoryPath, originURL string) error {
current, err := c.originURLLocked(ctx, repositoryPath)
if err != nil {
return err
}
if current == strings.TrimSpace(originURL) {
return nil
}
cmd := subproc.NewGitCommand(ctx, "-C", repositoryPath, "remote", "set-url", "origin", "--", originURL)
cleanup, err := configureGitCommand(cmd, nil)
if err != nil {
return err
}
output, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd)
cleanup()
if err != nil {
return fmt.Errorf("set repository origin: %w", formatGitOriginError(repositoryPath, output, err))
}
return nil
}
func (c *Cloner) verifyOriginURLLocked(ctx context.Context, repositoryPath, originURL string) error {
current, err := c.originURLLocked(ctx, repositoryPath)
if err != nil {
return err
}
if current != strings.TrimSpace(originURL) {
return fmt.Errorf("%w: checkout %q", ErrManagedCloneOriginMismatch, repositoryPath)
}
return nil
}
func (c *Cloner) originURLLocked(ctx context.Context, repositoryPath string) (string, error) {
// `git remote get-url` expands url.*.insteadOf rules before returning the
// value. Read the local config so canonical origins do not get rewritten on
// every launch or resume.
cmd := subproc.NewGitCommand(ctx, "-C", repositoryPath, "config", "--local", "--get", "remote.origin.url")
cleanup, err := configureGitCommand(cmd, nil)
if err != nil {
return "", err
}
currentOutput, err := subproc.RunGitCombinedOutputClass(ctx, subproc.GitLifecycle, cmd)
cleanup()
if err != nil {
return "", fmt.Errorf("inspect repository origin: %w", formatGitOriginError(repositoryPath, currentOutput, err))
}
return strings.TrimSpace(string(currentOutput)), nil
}
func formatGitOriginError(repositoryPath string, output []byte, err error) error {
diagnostic := redactCloneOutput(string(output), "")
if strings.Contains(strings.ToLower(diagnostic), "detected dubious ownership") {
return fmt.Errorf(
"%w: Git rejected managed checkout %q because its filesystem owner differs from the Kandev service account; ensure that account owns the checkout or reinstall Kandev with the intended account (git: %s)",
ErrRepositoryOwnershipMismatch, repositoryPath, diagnostic,
)
}
if diagnostic == "" {
return err
}
return fmt.Errorf("git reported %s: %w", diagnostic, err)
}
type cloneAuth struct {
origin string
username string
password string
}
func (c *Cloner) workspaceCloneAuth(
ctx context.Context, workspaceID, provider, providerHost, cloneURL, owner, name, credentialOrigin, token string,
) (string, *cloneAuth, error) {
return c.workspaceCloneAuthRequest(ctx, GitCredentialRequest{
WorkspaceID: workspaceID, Provider: provider, ProviderHost: providerHost,
CloneURL: cloneURL, Owner: owner, Name: name,
}, credentialOrigin, token)
}
func (c *Cloner) workspaceCloneAuthRequest(
ctx context.Context, request GitCredentialRequest, credentialOrigin, token string,
) (string, *cloneAuth, error) {
provider := strings.ToLower(strings.TrimSpace(request.Provider))
if provider == "" {
provider = githubProvider
}
if provider != githubProvider && (provider == gitlabProvider || provider == "azure_devops" || strings.TrimSpace(token) != "") {
auth, err := credentialAuth(request.CloneURL, credentialOrigin, token)
return request.CloneURL, auth, err
}
httpsURL := request.CloneURL
if provider == githubProvider {
var err error
httpsURL, err = githubHTTPSCloneURL(
request.CloneURL, request.ProviderHost, request.Owner, request.Name,
)
if err != nil {
return "", nil, err
}
} else if err := ValidateHTTPSCloneOrigin(request.CloneURL, request.ProviderHost); err != nil {
return "", nil, fmt.Errorf("validate provider clone origin: %w", err)
}
if c.credentials == nil {
return "", nil, ErrWorkspaceCredentialUnavailable
}
request.Provider = provider
request.CloneURL = httpsURL
username, password, err := c.credentials.ResolveGitCredential(ctx, request)
if err != nil {
return "", nil, fmt.Errorf("resolve workspace Git credential: %w", err)
}
if strings.TrimSpace(password) == "" {
return "", nil, ErrWorkspaceCredentialUnavailable
}
parsed, err := url.Parse(httpsURL)
if err != nil || parsed.Host == "" {
return "", nil, fmt.Errorf("parse managed clone URL %q: host is required", request.CloneURL)
}
if username == "" {
username = "x-access-token"
}
return httpsURL, &cloneAuth{
origin: parsed.Scheme + "://" + parsed.Host, username: username, password: password,
}, nil
}
func githubHTTPSCloneURL(cloneURL, providerHost, owner, name string) (string, error) {
expectedHost := strings.TrimSpace(providerHost)
if expectedHost == "" {
expectedHost = githubHTTPSOrigin
}
if ValidateHTTPSCloneOrigin(cloneURL, expectedHost) == nil {
return strings.TrimSpace(cloneURL), nil
}
return CloneURLWithHost(githubProvider, expectedHost, owner, name, ProtocolHTTPS)
}
func credentialAuth(cloneURL, credentialOrigin, token string) (*cloneAuth, error) {
if strings.TrimSpace(token) == "" {
return nil, nil
}
if !isHTTPCloneURL(cloneURL) {
return nil, validateSSHCredentialHost(cloneURL, credentialOrigin)
}
origin, err := matchingCredentialOrigin(cloneURL, credentialOrigin)
if err != nil {
return nil, err
}
return &cloneAuth{origin: origin, username: "oauth2", password: token}, nil
}
func (c *Cloner) ensureClonedAtPath(
ctx context.Context, cloneURL, targetPath string, auth *cloneAuth,
) (string, error) {
return c.ensureClonedAtPathWithOriginVerification(ctx, cloneURL, targetPath, auth, false)
}
func (c *Cloner) ensureClonedAtPathWithOriginVerification(
ctx context.Context, cloneURL, targetPath string, auth *cloneAuth, verifyExistingOrigin bool,
) (string, error) {
mu := c.repoMu(targetPath)
mu.Lock()
defer mu.Unlock()
gitDir := filepath.Join(targetPath, ".git")
if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() {
if verifyExistingOrigin {
if err := c.verifyOriginURLLocked(ctx, targetPath, cloneURL); err != nil {
return targetPath, err
}
}
c.fetch(ctx, targetPath, auth)
return targetPath, nil
}
return targetPath, c.clone(ctx, cloneURL, targetPath, auth)
}
// EnsureClonedWithBasicAuth retains the legacy basic-auth clone path.
func (c *Cloner) EnsureClonedWithBasicAuth(
ctx context.Context, cloneURL, owner, name, username, password string,
) (string, error) {
targetPath, err := c.RepoPath(owner, name)
if err != nil {
return "", err
}
return c.ensureClonedWithBasicAuth(ctx, targetPath, cloneURL, username, password)
}
// EnsureWorkspaceClonedWithBasicAuth keeps basic authentication in the
// short-lived Git helper file descriptors and stores the clone under the
// workspace/provider/origin path.
func (c *Cloner) EnsureWorkspaceClonedWithBasicAuth(
ctx context.Context, workspaceID, provider, providerHost,
cloneURL, owner, name, username, password string,
) (string, error) {
targetPath, err := c.WorkspaceProviderRepoPath(workspaceID, provider, providerHost, owner, name)
if err != nil {
return "", err
}
return c.ensureClonedWithBasicAuth(ctx, targetPath, cloneURL, username, password)
}
func (c *Cloner) ensureClonedWithBasicAuth(
ctx context.Context, targetPath, cloneURL, username, password string,
) (string, error) {
mu := c.repoMu(targetPath)
mu.Lock()
defer mu.Unlock()
origin, err := gitCredentialOrigin(cloneURL)
if err != nil {
return targetPath, err
}
auth := &cloneAuth{origin: origin, username: username, password: password}
gitDir := filepath.Join(targetPath, ".git")
if info, statErr := os.Stat(gitDir); statErr == nil && info.IsDir() {
if err := c.verifyOriginURLLocked(ctx, targetPath, cloneURL); err != nil {
return targetPath, err
}
c.fetch(ctx, targetPath, auth)
return targetPath, nil
}
return targetPath, c.clone(ctx, cloneURL, targetPath, auth)
}
func gitCredentialOrigin(cloneURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(cloneURL))
if err != nil || parsed.Scheme != ProtocolHTTPS || parsed.Host == "" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("parse managed clone URL %q: HTTPS host is required", cloneURL)
}
return parsed.Scheme + "://" + parsed.Host, nil
}
func (c *Cloner) fetch(ctx context.Context, repoPath string, auth *cloneAuth) {
c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath))
args := []string{"-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags}
if out, err := c.runConfiguredGitCombined(ctx, gitFetchTimeout, args,
func(cmd *exec.Cmd) (func(), error) { return configureGitCommand(cmd, auth) },
); err != nil {
c.logger.Warn("git fetch failed (non-fatal)",
zap.String("path", repoPath), zap.String("output", redactCloneOutput(string(out), authToken(auth))), zap.Error(err))
}
}
func (c *Cloner) clone(ctx context.Context, cloneURL, targetPath string, auth *cloneAuth) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
c.logger.Info("cloning repository", zap.String("url", redactCloneURL(cloneURL)), zap.String("target", targetPath))
args := []string{"clone"}
if auth == nil {
args = append(args, "--filter=blob:none")
}
args = append(args, gitNoTags, "--", cloneURL, targetPath)
if out, err := c.runConfiguredGitCombined(ctx, gitCloneTimeout, args,
func(cmd *exec.Cmd) (func(), error) { return configureGitCommand(cmd, auth) },
); err != nil {
return fmt.Errorf("git clone failed: %s: %w", redactCloneOutput(string(out), authToken(auth)), err)
}
return nil
}
func (c *Cloner) fetchWithHTTPHeader(ctx context.Context, repoPath, authURL, header string) {
c.logger.Debug("repository already cloned, fetching", zap.String("path", repoPath))
args := []string{"-C", repoPath, "fetch", "--all", "--prune", "--force", gitNoTags}
if out, err := c.runConfiguredGitCombined(ctx, gitFetchTimeout, args,
func(cmd *exec.Cmd) (func(), error) {
configureHTTPHeaderCommand(cmd, authURL, header)
return func() {}, nil
},
); err != nil {
c.logger.Warn("authenticated git fetch failed (non-fatal)",
zap.String("path", repoPath), zap.String("output", string(out)), zap.Error(err))
}
}
func (c *Cloner) cloneWithHTTPHeader(ctx context.Context, cloneURL, targetPath, header string) error {
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("create parent directory: %w", err)
}
c.logger.Info("cloning authenticated repository", zap.String("url", redactCloneURL(cloneURL)), zap.String("target", targetPath))
args := []string{"clone", "--filter=blob:none", gitNoTags, "--", cloneURL, targetPath}
if out, err := c.runConfiguredGitCombined(ctx, gitCloneTimeout, args,
func(cmd *exec.Cmd) (func(), error) {
configureHTTPHeaderCommand(cmd, cloneURL, header)
return func() {}, nil
},
); err != nil {
return fmt.Errorf("git clone failed: %s: %w", string(out), err)
}
return nil
}
func (c *Cloner) runConfiguredGitCombined(
ctx context.Context,
execTimeout time.Duration,
args []string,
configure func(*exec.Cmd) (func(), error),
) ([]byte, error) {
template := subproc.NewGitCommand(ctx, args...)
cleanup, err := configure(template)
if err != nil {
return nil, err
}
defer cleanup()
output, runErr, execCtxErr := subproc.RunGitCombinedAfterAcquire(
ctx,
subproc.GitLifecycle,
execTimeout,
func(execCtx context.Context) *exec.Cmd {
cmd := subproc.NewGitCommand(execCtx, args...)
cmd.Dir = template.Dir
cmd.Env = append([]string(nil), template.Env...)
cmd.ExtraFiles = append([]*os.File(nil), template.ExtraFiles...)
return cmd
},
)
if runErr == nil {
runErr = execCtxErr
}
return output, runErr
}
func configureGitCommand(cmd *exec.Cmd, auth *cloneAuth) (func(), error) {
env := cleanGitEnvironment()
if auth == nil {
env = append(env,
"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=",
)
cmd.Env = env
return func() {}, nil
}
if err := validateCloneAuth(auth); err != nil {
return nil, err
}
helperPath, files, helperEnv, cleanup, err := gitCredentialHelperCommand(auth)
if err != nil {
return nil, err
}
env = append(env,
"GIT_CONFIG_COUNT=3",
"GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=",
"GIT_CONFIG_KEY_1=credential."+auth.origin+".helper", "GIT_CONFIG_VALUE_1=!"+helperPath,
"GIT_CONFIG_KEY_2=credential.useHttpPath", "GIT_CONFIG_VALUE_2=true",
)
env = append(env, helperEnv...)
cmd.Env = env
cmd.ExtraFiles = files
return cleanup, nil
}
func configureHTTPHeaderCommand(cmd *exec.Cmd, authURL, header string) {
cmd.Env = append(cleanGitEnvironment(),
"GIT_CONFIG_COUNT=2",
"GIT_CONFIG_KEY_0=credential.helper", "GIT_CONFIG_VALUE_0=",
"GIT_CONFIG_KEY_1=http."+authURL+".extraHeader", "GIT_CONFIG_VALUE_1="+header,
)
}
func validateCloneAuth(auth *cloneAuth) error {
if auth == nil || strings.TrimSpace(auth.password) == "" || len(auth.password) > gitCredentialMaxBytes ||
strings.ContainsAny(auth.username, "\r\n") || strings.ContainsAny(auth.password, "\r\n") {
return ErrWorkspaceCredentialUnavailable
}
return nil
}
func cleanGitEnvironment() []string {
env := withoutEnv(os.Environ(),
"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", gitHubCredentialEnv, gitHubCredentialUserEnv,
gitCredentialUsernameFileEnv, gitCredentialPasswordFileEnv,
"GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
"GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_NOSYSTEM",
)
return append(env,
"GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull,
)
}
func withoutEnv(env []string, keys ...string) []string {
blocked := make(map[string]struct{}, len(keys))
for _, key := range keys {
blocked[key] = struct{}{}
}
result := make([]string, 0, len(env))
for _, entry := range env {
key, _, _ := strings.Cut(entry, "=")
_, remove := blocked[key]
if remove || strings.HasPrefix(key, "GIT_CONFIG_KEY_") || strings.HasPrefix(key, "GIT_CONFIG_VALUE_") {
continue
}
result = append(result, entry)
}
return result
}
func isHTTPCloneURL(cloneURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(cloneURL))
return err == nil && (parsed.Scheme == protocolHTTP || parsed.Scheme == ProtocolHTTPS)
}
func validateSSHCredentialHost(cloneURL, credentialOrigin string) error {
credential, err := url.Parse(strings.TrimRight(strings.TrimSpace(credentialOrigin), "/"))
if err != nil || (credential.Scheme != protocolHTTP && credential.Scheme != ProtocolHTTPS) ||
credential.Hostname() == "" || credential.User != nil ||
(credential.Path != "" && credential.Path != "/") || credential.RawQuery != "" || credential.Fragment != "" {
return errors.New("configured credential host is invalid")
}
remoteHost := sshCloneHostname(cloneURL)
if remoteHost == "" || !strings.EqualFold(remoteHost, credential.Hostname()) {
return errors.New("clone URL does not match the configured credential host")
}
return nil
}
func sshCloneHostname(cloneURL string) string {
trimmed := strings.TrimSpace(cloneURL)
if strings.HasPrefix(trimmed, "ssh://") {
parsed, err := url.Parse(trimmed)
if err == nil && parsed.Hostname() != "" && parsed.Path != "" {
return parsed.Hostname()
}
return ""
}
if _, after, ok := strings.Cut(trimmed, "@"); ok {
trimmed = after
}
host, path, ok := strings.Cut(trimmed, ":")
if !ok || host == "" || path == "" {
return ""
}
return host
}
func matchingCredentialOrigin(cloneURL, credentialOrigin string) (string, error) {
clone, cloneErr := url.Parse(strings.TrimSpace(cloneURL))
credential, credentialErr := url.Parse(strings.TrimRight(strings.TrimSpace(credentialOrigin), "/"))
if cloneErr != nil || credentialErr != nil || clone.User != nil || credential.User != nil ||
(clone.Scheme != protocolHTTP && clone.Scheme != ProtocolHTTPS) ||
clone.Scheme != credential.Scheme || !strings.EqualFold(clone.Host, credential.Host) ||
(credential.Path != "" && credential.Path != "/") {
return "", errors.New("clone URL does not match the configured credential host")
}
return credential.Scheme + "://" + credential.Host, nil
}
func redactCloneURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
parsed.User = nil
return parsed.String()
}
func redactCloneOutput(output, token string) string {
redacted := output
if token != "" {
redacted = strings.ReplaceAll(redacted, token, "[REDACTED]")
}
redacted = gitURLUserInfoPattern.ReplaceAllString(redacted, `${1}[REDACTED]@`)
redacted = gitCredentialPattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
if len(redacted) > maxGitDiagnosticBytes {
return redacted[:maxGitDiagnosticBytes]
}
return redacted
}
func authToken(auth *cloneAuth) string {
if auth == nil {
return ""
}
return auth.password
}