2048 lines · 67.4 KB · Go
RawDownload
package store import (	"context"	"encoding/json"	"errors"	"fmt"	"io/fs"	"os"	"path/filepath"	"sort"	"strings"	"sync"	"time" 	"github.com/go-git/go-git/v6"	"github.com/go-git/go-git/v6/config"	"github.com/go-git/go-git/v6/plumbing"	"github.com/go-git/go-git/v6/plumbing/client"	gitindex "github.com/go-git/go-git/v6/plumbing/format/index"	"github.com/go-git/go-git/v6/plumbing/object"	"github.com/go-git/go-git/v6/plumbing/transport"	"github.com/go-git/go-git/v6/plumbing/transport/http"	"github.com/go-git/go-git/v6/storage/filesystem/dotgit"	cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"	log "github.com/sirupsen/logrus") const (	// gcInterval defines minimum time between garbage collection runs.	gcInterval = 5 * time.Minute	// gcPruneGracePeriod keeps recently orphaned objects available for recovery.	gcPruneGracePeriod = 24 * time.Hour) // GitTokenStore persists token records and auth metadata using git as the backing storage.type GitTokenStore struct {	mu        sync.Mutex	dirLock   sync.RWMutex	baseDir   string	repoDir   string	configDir string	remote    string	branch    string	username  string	password  string	lastGC    time.Time} type resolvedRemoteBranch struct {	name plumbing.ReferenceName	hash plumbing.Hash} // NewGitTokenStore creates a token store that saves credentials to disk through the// TokenStorage implementation embedded in the token record.// When branch is non-empty, clone/pull/push operations target that branch instead of the remote default.func NewGitTokenStore(remote, username, password, branch string) *GitTokenStore {	return &GitTokenStore{		remote:   remote,		branch:   strings.TrimSpace(branch),		username: username,		password: password,	}} // SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.func (s *GitTokenStore) SetBaseDir(dir string) {	s.mu.Lock()	defer s.mu.Unlock() 	clean := strings.TrimSpace(dir)	if clean == "" {		s.dirLock.Lock()		s.baseDir = ""		s.repoDir = ""		s.configDir = ""		s.dirLock.Unlock()		return	}	if abs, err := filepath.Abs(clean); err == nil {		clean = abs	}	repoDir := filepath.Dir(clean)	if repoDir == "" || repoDir == "." {		repoDir = clean	}	configDir := filepath.Join(repoDir, "config")	s.dirLock.Lock()	s.baseDir = clean	s.repoDir = repoDir	s.configDir = configDir	s.dirLock.Unlock()} // AuthDir returns the directory used for auth persistence.func (s *GitTokenStore) AuthDir() string {	return s.baseDirSnapshot()} // ConfigPath returns the managed config file path.func (s *GitTokenStore) ConfigPath() string {	s.dirLock.RLock()	defer s.dirLock.RUnlock()	if s.configDir == "" {		return ""	}	return filepath.Join(s.configDir, "config.yaml")} // EnsureRepository prepares the local git working tree by cloning or opening the repository.func (s *GitTokenStore) EnsureRepository() error {	s.mu.Lock()	defer s.mu.Unlock()	return s.ensureRepositoryLocked()} func (s *GitTokenStore) ensureRepositoryLocked() (errResult error) {	s.dirLock.Lock()	if s.remote == "" {		s.dirLock.Unlock()		return fmt.Errorf("git token store: remote not configured")	}	if s.baseDir == "" {		s.dirLock.Unlock()		return fmt.Errorf("git token store: base directory not configured")	}	repoDir := s.repoDir	if repoDir == "" {		repoDir = filepath.Dir(s.baseDir)		if repoDir == "" || repoDir == "." {			repoDir = s.baseDir		}		s.repoDir = repoDir	}	if s.configDir == "" {		s.configDir = filepath.Join(repoDir, "config")	}	authDir := filepath.Join(repoDir, "auths")	configDir := filepath.Join(repoDir, "config")	gitDir := filepath.Join(repoDir, ".git")	authMethod := s.gitClientOptions()	var initPaths []string	if _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) {		if errMk := os.MkdirAll(repoDir, 0o700); errMk != nil {			s.dirLock.Unlock()			return fmt.Errorf("git token store: create repo dir: %w", errMk)		}		cloneOpts := &git.CloneOptions{ClientOptions: authMethod, URL: s.remote}		if s.branch != "" {			cloneOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)		}		if cloned, errClone := git.PlainClone(repoDir, cloneOpts); errClone != nil {			if errors.Is(errClone, transport.ErrEmptyRemoteRepository) {				_ = os.RemoveAll(gitDir)				repo, errInit := git.PlainInit(repoDir, false)				if errInit != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: init empty repo: %w", errInit)				}				defer func() {					if errClose := repo.Close(); errClose != nil {						errCloseWrap := fmt.Errorf("git token store: close initialized empty repo: %w", errClose)						if errResult == nil {							errResult = errCloseWrap						} else {							errResult = errors.Join(errResult, errCloseWrap)						}					}				}()				if s.branch != "" {					headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(s.branch))					if errHead := repo.Storer.SetReference(headRef); errHead != nil {						s.dirLock.Unlock()						return fmt.Errorf("git token store: set head to branch %s: %w", s.branch, errHead)					}				}				if _, errRemote := repo.Remote("origin"); errRemote != nil {					if _, errCreate := repo.CreateRemote(&config.RemoteConfig{						Name: "origin",						URLs: []string{s.remote},					}); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) {						s.dirLock.Unlock()						return fmt.Errorf("git token store: configure remote: %w", errCreate)					}				}				if err := os.MkdirAll(authDir, 0o700); err != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: create auth dir: %w", err)				}				if err := os.MkdirAll(configDir, 0o700); err != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: create config dir: %w", err)				}				if err := ensureEmptyFile(filepath.Join(authDir, ".gitkeep")); err != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: create auth placeholder: %w", err)				}				if err := ensureEmptyFile(filepath.Join(configDir, ".gitkeep")); err != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: create config placeholder: %w", err)				}				initPaths = []string{					filepath.Join("auths", ".gitkeep"),					filepath.Join("config", ".gitkeep"),				}			} else {				s.dirLock.Unlock()				return fmt.Errorf("git token store: clone remote: %w", errClone)			}		} else if cloned != nil {			if errClose := cloned.Close(); errClose != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: close cloned repo: %w", errClose)			}		}	} else if err != nil {		s.dirLock.Unlock()		return fmt.Errorf("git token store: stat repo: %w", err)	} else {		repo, errOpen := git.PlainOpen(repoDir)		if errOpen != nil {			s.dirLock.Unlock()			return fmt.Errorf("git token store: open repo: %w", errOpen)		}		defer func() {			if repo != nil {				if errClose := repo.Close(); errClose != nil {					errCloseWrap := fmt.Errorf("git token store: close repo: %w", errClose)					if errResult == nil {						errResult = errCloseWrap					} else {						errResult = errors.Join(errResult, errCloseWrap)					}				}			}		}()		worktree, errWorktree := repo.Worktree()		if errWorktree != nil {			s.dirLock.Unlock()			return fmt.Errorf("git token store: worktree: %w", errWorktree)		}		if errVerify := verifyRepositoryHead(repo); errVerify != nil {			if !isRepositoryCorruptionError(errVerify) {				s.dirLock.Unlock()				return fmt.Errorf("git token store: verify repository before pull: %w", errVerify)			}			errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, nil, nil, (*git.Repository).Close, os.Rename)			repo = nil			if errRecover != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: verify repository before pull: %w; recovery failed: %w", errVerify, errRecover)			}			repo, errOpen = git.PlainOpen(repoDir)			if errOpen != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: open recovered repo: %w", errOpen)			}			worktree, errWorktree = repo.Worktree()			if errWorktree != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: recovered worktree: %w", errWorktree)			}		}		if s.branch != "" {			if errCheckout := s.checkoutConfiguredBranch(repo, worktree, authMethod); errCheckout != nil {				s.dirLock.Unlock()				return errCheckout			}		} else {			// When branch is unset, ensure the working tree follows the remote default branch			if err := checkoutRemoteDefaultBranch(repo, worktree, authMethod); err != nil {				if !shouldFallbackToCurrentBranch(repo, err) {					s.dirLock.Unlock()					return fmt.Errorf("git token store: checkout remote default: %w", err)				}			}		}		pullOpts := &git.PullOptions{ClientOptions: authMethod, RemoteName: "origin"}		if s.branch != "" {			pullOpts.ReferenceName = plumbing.NewBranchReferenceName(s.branch)		}		prePullHead, errPrePullHead := repo.Head()		if errPrePullHead != nil && !errors.Is(errPrePullHead, plumbing.ErrReferenceNotFound) {			s.dirLock.Unlock()			return fmt.Errorf("git token store: get head before pull: %w", errPrePullHead)		}		var prePullTree *object.Tree		if prePullHead != nil {			prePullCommit, errPrePullCommit := repo.CommitObject(prePullHead.Hash())			if errPrePullCommit != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: inspect head before pull: %w", errPrePullCommit)			}			prePullTree, errPrePullCommit = prePullCommit.Tree()			if errPrePullCommit != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: inspect tree before pull: %w", errPrePullCommit)			}		}		dirtyPaths, errDirtyPaths := worktreeDirtyPaths(worktree)		if errDirtyPaths != nil {			s.dirLock.Unlock()			return fmt.Errorf("git token store: inspect worktree before pull: %w", errDirtyPaths)		}		repositoryRecovered := false		if errPull := worktree.Pull(pullOpts); errPull != nil {			switch {			case errors.Is(errPull, git.NoErrAlreadyUpToDate):				if errReset := resetIndexToHead(repo, worktree); errReset != nil {					if !isRepositoryCorruptionError(errReset) {						s.dirLock.Unlock()						return fmt.Errorf("git token store: repair index after up-to-date pull: %w", errReset)					}					errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)					repo = nil					if errRecover != nil {						s.dirLock.Unlock()						return fmt.Errorf("git token store: repair index after up-to-date pull: %w; recovery failed: %w", errReset, errRecover)					}					repositoryRecovered = true				}			case errors.Is(errPull, git.ErrUnstagedChanges), errors.Is(errPull, git.ErrNonFastForwardUpdate):				if prePullHead == nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: reconcile pull without a local branch")				}				if errReconcile := reconcileRemoteWorktree(repo, worktree, repoDir, prePullHead, dirtyPaths); errReconcile != nil {					if !isRepositoryCorruptionError(errReconcile) {						s.dirLock.Unlock()						return fmt.Errorf("git token store: reconcile remote changes: %w", errReconcile)					}					errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)					repo = nil					if errRecover != nil {						s.dirLock.Unlock()						return fmt.Errorf("git token store: reconcile remote changes: %w; recovery failed: %w", errReconcile, errRecover)					}					repositoryRecovered = true				}			case errors.Is(errPull, transport.ErrAuthenticationRequired),				errors.Is(errPull, transport.ErrEmptyRemoteRepository):				// Ignore authentication prompts and empty remote references on initial sync.			case errors.Is(errPull, plumbing.ErrReferenceNotFound):				if s.branch != "" {					s.dirLock.Unlock()					return fmt.Errorf("git token store: pull: %w", errPull)				}				// Ignore missing references only when following the remote default branch.			case isRepositoryCorruptionError(errPull):				errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)				repo = nil				if errRecover != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: pull: %w; recovery failed: %w", errPull, errRecover)				}				repositoryRecovered = true			default:				s.dirLock.Unlock()				return fmt.Errorf("git token store: pull: %w", errPull)			}		}		if !repositoryRecovered {			if errVerify := verifyRepositoryHead(repo); errVerify != nil {				if !isRepositoryCorruptionError(errVerify) {					s.dirLock.Unlock()					return fmt.Errorf("git token store: verify repository after pull: %w", errVerify)				}				errRecover := s.recoverRepositoryLocked(repoDir, authMethod, repo, prePullTree, dirtyPaths, (*git.Repository).Close, os.Rename)				repo = nil				if errRecover != nil {					s.dirLock.Unlock()					return fmt.Errorf("git token store: verify repository after pull: %w; recovery failed: %w", errVerify, errRecover)				}				repositoryRecovered = true			}		}		if !repositoryRecovered {			if errRestore := restoreMissingTrackedFiles(repo, repoDir); errRestore != nil {				s.dirLock.Unlock()				return fmt.Errorf("git token store: restore tracked worktree files: %w", errRestore)			}		}	}	if err := disableGitCommitSigning(repoDir); err != nil {		s.dirLock.Unlock()		return err	}	if err := os.MkdirAll(s.baseDir, 0o700); err != nil {		s.dirLock.Unlock()		return fmt.Errorf("git token store: create auth dir: %w", err)	}	if err := os.MkdirAll(s.configDir, 0o700); err != nil {		s.dirLock.Unlock()		return fmt.Errorf("git token store: create config dir: %w", err)	}	s.dirLock.Unlock()	if len(initPaths) > 0 {		if errCommit := s.commitAndPushInitialLocked("Initialize git token store", initPaths...); errCommit != nil {			return errCommit		}	}	return nil} // Save persists token storage and metadata to the resolved auth file path.func (s *GitTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {	if auth == nil {		return "", fmt.Errorf("auth filestore: auth is nil")	}	cliproxyauth.NormalizeCredentialMetadata(auth.Metadata)	if errWeight := cliproxyauth.ValidateAuthWeight(auth); errWeight != nil {		return "", fmt.Errorf("auth filestore: %w", errWeight)	} 	s.mu.Lock()	defer s.mu.Unlock() 	path, err := s.resolveAuthPath(auth)	if err != nil {		return "", err	}	if path == "" {		return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)	} 	// Runtime updates must not recreate a disabled credential whose source file	// was deliberately removed. Login and migration callers explicitly mark the	// save when creating a missing disabled credential is intentional.	if auth.Disabled && !cliproxyauth.HasAuthCreationIntent(ctx) {		if _, statErr := os.Stat(path); os.IsNotExist(statErr) {			return "", nil		}	} 	if err = s.ensureRepositoryLocked(); err != nil {		return "", err	}	relPath, errRel := s.relativeToRepo(path)	if errRel != nil {		return "", errRel	}	if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {		return "", fmt.Errorf("auth filestore: create dir failed: %w", err)	} 	switch {	case auth.Storage != nil:		if auth.Metadata == nil {			auth.Metadata = make(map[string]any)		}		auth.Metadata["disabled"] = auth.Disabled		if setter, ok := auth.Storage.(interface{ SetMetadata(map[string]any) }); ok {			setter.SetMetadata(auth.Metadata)		}		if err = auth.Storage.SaveTokenToFile(path); err != nil {			return "", err		}	case auth.Metadata != nil:		auth.Metadata["disabled"] = auth.Disabled		raw, errMarshal := json.Marshal(auth.Metadata)		if errMarshal != nil {			return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)		}		contentsMatch := false		if existing, errRead := os.ReadFile(path); errRead == nil {			contentsMatch = jsonEqual(existing, raw)		} else if !os.IsNotExist(errRead) {			return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)		}		if !contentsMatch {			tmp := path + ".tmp"			if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {				return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)			}			if errRename := os.Rename(tmp, path); errRename != nil {				return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)			}		}	default:		return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)	} 	if auth.Attributes == nil {		auth.Attributes = make(map[string]string)	}	auth.Attributes[cliproxyauth.AttributePath] = path	auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceGit 	if strings.TrimSpace(auth.FileName) == "" {		auth.FileName = auth.ID	} 	messageID := auth.ID	if strings.TrimSpace(messageID) == "" {		messageID = filepath.Base(path)	}	if errCommit := s.commitAndPushLocked(fmt.Sprintf("Update auth %s", strings.TrimSpace(messageID)), relPath); errCommit != nil {		return "", errCommit	}

Lines 1–500 of 2048.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t