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. Load more
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 }