Files
larksuite__cli/shortcuts/apps/gitcred/lock.go
linchao5102 426f80fdcb fix(apps): isolate git credential helper chain for scoped repos (#2188)
Scoped repos created by Miaoda apps failed day-2 `git pull/push`
authentication because a stale, higher-priority credential helper (e.g.
macOS osxkeychain) served cached credentials instead of the lark-cli
helper. This isolates the URL-scoped credential helper chain and closes
correctness gaps found in review:

1. Helper-order isolation. Git's empty-helper ("") reset only clears
   helpers that parse BEFORE the lark-cli section; a generic
   credential.helper (or one from a later [include]) that parses AFTER it
   still participated in get/store/erase. SetHelper now verifies, via the
   faithful parse-order oracle (`git config --includes --show-origin -z
   --list`), that the lark-cli helper is last in fill order. If not, it
   repositions the section to the end of the writable file; if a later
   helper lives in a file we must not edit, it fails closed with a
   FailedPrecondition error and restores the prior state.

2. Concurrent read-modify-write. The read-modify-write of the writable
   global config file is serialized across lark-cli processes by a new
   cross-process lockGlobalConfig, ordered after lockApp and before
   lockURL. That lock cannot stop an unrelated (non-lark-cli) process, so
   the helper rewrite also no longer clears the whole key: it deletes only
   the values observed in the ownership snapshot by exact match, leaving a
   helper a third party inserted during the write window in place. The
   readback then diverges from the expected state and SetHelper fails
   closed with the foreign value preserved, instead of a whole-key
   --unset-all silently deleting it.

3. Recoverable teardown. UnsetHelper now deletes useHttpPath before the
   helper list so a mid-teardown failure leaves a lark-cli-recoverable
   residue (never a useHttpPath-only orphan that blocks re-init), and
   removes only lark-cli values from a mixed list, reporting that a
   third-party helper remains rather than silently no-op'ing. The state
   taxonomy gains Foreign/Partial/Mixed to drive these paths.
2026-08-06 15:16:11 +08:00

130 lines
5.4 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcred manages the lifecycle of app Git credentials.
//
// Lock ordering convention — read this before adding any new lock acquisition:
//
// ALWAYS acquire lockApp BEFORE lockGlobalConfig, and lockGlobalConfig
// BEFORE lockURL. Never invert this order.
//
// Rationale:
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
// possible setup error; acquiring it first keeps the failure surface
// outside any in-process lock and avoids holding the in-process mutex
// while waiting on I/O / another process.
// - lockGlobalConfig is a cross-process file lock, keyed by the writable
// global Git config file path, that serializes the read-modify-write of
// that file across concurrent lark-cli processes. It sits below lockApp
// (which is finer-grained, per app) and above lockURL.
// - lockURL is an in-process sync.Mutex that never fails and blocks
// indefinitely; holding it while waiting on a file lock would risk
// deadlocking with a concurrent goroutine that held the file lock first.
//
// Paths that only manipulate per-app state (Init, Remove, Erase) take lockApp.
// SetHelper/UnsetHelper additionally take lockGlobalConfig because they perform
// a read-modify-write on the shared global config file. Get() is the only path
// that touches per-URL state in addition to per-app state, so it is the only
// caller that takes lockURL.
package gitcred
import (
"errors"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O.
)
var urlLocks sync.Map
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
// lockURL acquires an in-process, per-URL mutex. It never returns an error
// and blocks until the mutex is available.
//
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
// comment for the full convention.
func lockURL(url string) func() {
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
mu := actual.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// lockApp acquires a cross-process file lock scoped to the given appID. It
// returns an unlock function or an error if the lock directory cannot be
// created or the lock cannot be acquired within the 2s timeout.
//
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
// taken FIRST. See package comment for the full convention.
func lockApp(appID string) (func(), error) {
dir := filepath.Join(core.GetConfigDir(), "locks")
if err := vfs.MkdirAll(dir, 0700); err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
}
name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock"
return acquireFileLock(filepath.Join(dir, filepath.Base(name)))
}
// lockGlobalConfig acquires a cross-process file lock that serializes the
// read-modify-write of the writable global Git config file identified by
// origin (a git "file:" origin or a bare path). It is keyed by the config file
// path so concurrent lark-cli processes editing the same global config are
// serialized while edits to different config files proceed independently.
//
// Lock ordering: lockGlobalConfig must be taken AFTER lockApp and BEFORE
// lockURL. See package comment for the full convention.
func lockGlobalConfig(origin string) (func(), error) {
dir := filepath.Join(core.GetConfigDir(), "locks")
if err := vfs.MkdirAll(dir, 0700); err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(err)
}
name := globalConfigLockName(origin)
unlock, err := acquireFileLock(filepath.Join(dir, filepath.Base(name)))
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire global Git config lock: %v", err).WithCause(err)
}
return unlock, nil
}
// globalConfigLockName derives the cross-process lock file name for a writable
// global config origin. GIT_CONFIG_GLOBAL may be a relative path, which would
// otherwise make the lock name (and therefore the lock file) depend on the
// caller's working directory; joining it onto the current directory ensures
// every process locking the same config file agrees on the lock regardless of
// cwd. It falls back to the raw path if the cwd is unavailable.
func globalConfigLockName(origin string) string {
path := strings.TrimPrefix(origin, "file:")
if !filepath.IsAbs(path) {
if cwd, err := vfs.Getwd(); err == nil {
path = filepath.Join(cwd, path)
}
}
return "apps_git_credential_globalcfg_" + safeLockNameChars.ReplaceAllString(path, "_") + ".lock"
}
// acquireFileLock takes the cross-process lock at lockPath, retrying on
// contention until a 2s deadline. It returns an unlock function or an error if
// the lock cannot be acquired within the timeout.
func acquireFileLock(lockPath string) (func(), error) {
lock := lockfile.New(lockPath)
deadline := time.Now().Add(2 * time.Second)
for {
err := lock.TryLock()
if err == nil {
return func() { _ = lock.Unlock() }, nil
}
if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) {
return nil, err
}
time.Sleep(50 * time.Millisecond)
}
}