chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type GitConfig interface {
|
||||
SetHelper(ctx context.Context, gitHTTPURL, appID string) error
|
||||
UnsetHelper(ctx context.Context, gitHTTPURL string) error
|
||||
}
|
||||
|
||||
type GlobalGitConfig struct {
|
||||
HelperCommand string
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appID = strings.TrimSpace(appID)
|
||||
if err := validate.ResourceName(appID, "appID"); err != nil {
|
||||
return err
|
||||
}
|
||||
helper := g.helperCommand(appID)
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
previousHelper, hadHelper, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", useHTTPPathKey, "true").Run(); err != nil {
|
||||
if !hadHelper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", "--unset", helperKey).Run()
|
||||
} else if previousHelper != helper {
|
||||
_ = exec.CommandContext(ctx, "git", "config", "--global", helperKey, previousHelper).Run()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error {
|
||||
normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helperKey := gitCredentialKey(normalizedURL, "helper")
|
||||
useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath")
|
||||
helper, found, err := gitConfigGet(ctx, helperKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if !g.isManagedHelper(helper) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := gitConfigUnset(ctx, helperKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gitConfigUnset(ctx, useHTTPPathKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) helperCommand(appID string) string {
|
||||
if g.HelperCommand != "" {
|
||||
return g.HelperCommand
|
||||
}
|
||||
return "!lark-cli apps git-credential-helper --app-id " + shellQuoteArg(appID)
|
||||
}
|
||||
|
||||
func (g GlobalGitConfig) isManagedHelper(helper string) bool {
|
||||
helper = strings.TrimSpace(helper)
|
||||
if g.HelperCommand != "" {
|
||||
return helper == g.HelperCommand
|
||||
}
|
||||
return strings.HasPrefix(helper, "!lark-cli apps git-credential-helper ")
|
||||
}
|
||||
|
||||
func gitCredentialKey(gitHTTPURL, name string) string {
|
||||
return "credential." + gitHTTPURL + "." + name
|
||||
}
|
||||
|
||||
func gitConfigGet(ctx context.Context, key string) (string, bool, error) {
|
||||
out, err := exec.CommandContext(ctx, "git", "config", "--global", "--get", key).Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), true, nil
|
||||
}
|
||||
if isGitConfigGetMissing(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
|
||||
func gitConfigUnset(ctx context.Context, key string) error {
|
||||
if err := exec.CommandContext(ctx, "git", "config", "--global", "--unset", key).Run(); err != nil {
|
||||
if isGitConfigUnsetMissing(err) {
|
||||
return nil
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isGitConfigGetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGitConfigUnsetMissing(err error) bool {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 5 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellQuoteArg(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type Issuer interface {
|
||||
Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error)
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
Store *Store
|
||||
Secrets *SecretStore
|
||||
GitConfig GitConfig
|
||||
Issuer Issuer
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// credentialKeys is the shared list of credential field names to redact; the
|
||||
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
||||
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
||||
|
||||
var (
|
||||
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
// credentialAssignmentRE matches credential key assignments, including JSON
|
||||
// quoted forms. Capture group 1 is the key and separator; only the value is
|
||||
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
||||
// single-quoted, or bare with a word boundary — so concatenated words like
|
||||
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
||||
// alternation does not bind the quote/boundary to only the first and last key.
|
||||
credentialAssignmentRE = regexp.MustCompile(
|
||||
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
||||
)
|
||||
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
||||
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
||||
)
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
Secrets: secrets,
|
||||
GitConfig: gitConfig,
|
||||
Issuer: issuer,
|
||||
Now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
if profile.UserOpenID == "" {
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`")
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
if m.Issuer == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "git credential issuer is not configured")
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, appID, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if err := validateIssuedCredential(appID, url, issued, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref := BuildPATRef(profile, appID)
|
||||
previous, err := m.currentAppRecord(appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var previousPAT string
|
||||
if previous != nil {
|
||||
previousPAT, _ = m.Secrets.Get(previous.PATRef)
|
||||
}
|
||||
record := CredentialRecord{
|
||||
AppID: appID,
|
||||
GitHTTPURL: url,
|
||||
Profile: profile.Profile,
|
||||
ProfileAppID: profile.ProfileAppID,
|
||||
UserOpenID: profile.UserOpenID,
|
||||
Username: defaultUsername(issued.Username),
|
||||
PATRef: ref,
|
||||
Status: StatusPending,
|
||||
ExpiresAt: issued.ExpiresAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Secrets.Set(ref, issued.PAT); err != nil {
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
record.Status = StatusConfirmed
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
if previous != nil && previous.PATRef == ref && previousPAT != "" {
|
||||
_ = m.Secrets.Set(ref, previousPAT)
|
||||
} else {
|
||||
_ = m.Secrets.Remove(ref)
|
||||
}
|
||||
m.restoreAfterInitFailure(appID, previous, previousPAT)
|
||||
return nil, err
|
||||
}
|
||||
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
|
||||
_ = m.Secrets.Remove(previous.PATRef)
|
||||
}
|
||||
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
} else if previous != nil && previous.GitHTTPURL != "" && previous.GitHTTPURL != url {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, previous.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) {
|
||||
appID = strings.TrimSpace(appID)
|
||||
if appID == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id")
|
||||
}
|
||||
if err := validate.ResourceName(appID, "--app-id"); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err)
|
||||
}
|
||||
unlockApp, err := lockApp(appID)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &RemoveResult{AppID: appID, Records: records}
|
||||
for _, record := range records {
|
||||
if err := m.Secrets.Remove(record.PATRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.GitConfig != nil {
|
||||
if err := m.GitConfig.UnsetHelper(ctx, record.GitHTTPURL); err != nil {
|
||||
result.ConfigWarning = err.Error()
|
||||
}
|
||||
}
|
||||
if _, err := m.Store.DeleteByURL(record.GitHTTPURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Removed = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) List() (*ListResult, error) {
|
||||
records, err := m.Store.Records()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ListRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, m.listRecord(record))
|
||||
}
|
||||
return &ListResult{Records: out}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
// Lock ordering convention (see lock.go package comment): always acquire
|
||||
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
||||
// timeout and possible setup failure; acquiring it first avoids holding an
|
||||
// in-process mutex on the failure path, which would risk a deadlock.
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
// lockApp may already return a typed error, for example when creating
|
||||
// the lock directory fails. Preserve those classifications and only wrap
|
||||
// raw lockfile errors to add app context.
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
unlockURL := lockURL(url)
|
||||
defer unlockURL()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if m.usable(record, pat) {
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
if m.Issuer == nil {
|
||||
fmt.Fprintln(errOut, "Git credential refresh failed: issuer is not configured")
|
||||
return nil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: issued repository URL %q does not match initialized URL %q\n", issuedURL, url)
|
||||
return nil
|
||||
}
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
writeCredentialError(errOut, "Git credential unavailable", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
return writeGitCredential(out, latest.Username, latestPAT)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
record.Username = defaultUsername(issued.Username)
|
||||
record.ExpiresAt = issued.ExpiresAt
|
||||
record.UpdatedAt = m.nowUnix()
|
||||
record.InvalidatedAt = 0
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func writeCredentialError(w io.Writer, prefix string, err error) {
|
||||
if w == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
||||
}
|
||||
|
||||
func safeCredentialErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
message := RedactCredentialText(p.Message)
|
||||
if p.LogID != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "log_id=" + p.LogID
|
||||
}
|
||||
if p.Hint != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "hint: " + RedactCredentialText(p.Hint)
|
||||
}
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
if p.Category != "" || p.Subtype != "" {
|
||||
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
||||
}
|
||||
}
|
||||
return RedactCredentialText(err.Error())
|
||||
}
|
||||
|
||||
// RedactCredentialText masks credential fragments that may appear in free
|
||||
// text, covering URL userinfo, Authorization bearer headers, credential
|
||||
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
||||
// the gitcred and apps packages so the redaction logic does not fork.
|
||||
func RedactCredentialText(text string) string {
|
||||
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
||||
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
||||
return text
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return &records[0], nil
|
||||
}
|
||||
|
||||
func (m *Manager) restoreAfterInitFailure(appID string, existing *CredentialRecord, existingPAT string) {
|
||||
if existing == nil {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err == nil {
|
||||
for _, record := range records {
|
||||
_, _ = m.Store.DeleteByURL(record.GitHTTPURL)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = m.Store.Upsert(*existing)
|
||||
if existingPAT != "" {
|
||||
_ = m.Secrets.Set(existing.PATRef, existingPAT)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) listRecord(record CredentialRecord) ListRecord {
|
||||
now := m.nowUnix()
|
||||
status := ListStatusValid
|
||||
expired := record.ExpiresAt <= now
|
||||
switch {
|
||||
case record.Status != StatusConfirmed || record.GitHTTPURL == "" || record.PATRef == "":
|
||||
status = ListStatusIncomplete
|
||||
case record.InvalidatedAt > 0:
|
||||
status = ListStatusInvalidated
|
||||
case !m.hasSecret(record.PATRef):
|
||||
status = ListStatusMissingSecret
|
||||
case expired:
|
||||
status = ListStatusExpired
|
||||
}
|
||||
return ListRecord{
|
||||
AppID: record.AppID,
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
Status: status,
|
||||
ExpiresAt: record.ExpiresAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
Profile: record.Profile,
|
||||
ProfileAppID: record.ProfileAppID,
|
||||
UserOpenID: record.UserOpenID,
|
||||
Expired: expired,
|
||||
InvalidatedAt: record.InvalidatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) hasSecret(ref string) bool {
|
||||
pat, err := m.Secrets.Get(ref)
|
||||
return err == nil && pat != ""
|
||||
}
|
||||
|
||||
func (m *Manager) StoreCredential(r io.Reader) error {
|
||||
_, err := io.Copy(io.Discard, r)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Erase(r io.Reader) error {
|
||||
input, err := ParseCredentialInput(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
defer unlockApp()
|
||||
record, err = m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
now := m.nowUnix()
|
||||
if record.LastEraseAt > 0 && now-record.LastEraseAt < int64(eraseCooldown.Seconds()) {
|
||||
return nil
|
||||
}
|
||||
record.InvalidatedAt = now
|
||||
record.LastEraseAt = now
|
||||
if err := m.Store.Upsert(*record); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Secrets.Remove(record.PATRef)
|
||||
}
|
||||
|
||||
func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialRecord, string, bool, error) {
|
||||
record, err := m.Store.FindByURL(url)
|
||||
if err != nil || record == nil {
|
||||
return CredentialRecord{}, "", false, err
|
||||
}
|
||||
if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID {
|
||||
return CredentialRecord{}, "", false, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential").
|
||||
WithHint(fmt.Sprintf("run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID))
|
||||
}
|
||||
pat, err := m.Secrets.Get(record.PATRef)
|
||||
if err != nil {
|
||||
pat = ""
|
||||
}
|
||||
return *record, pat, true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) usable(record CredentialRecord, pat string) bool {
|
||||
if record.Status != StatusConfirmed || pat == "" || record.InvalidatedAt > 0 {
|
||||
return false
|
||||
}
|
||||
return record.ExpiresAt-m.nowUnix() > int64(refreshBeforeExpiry.Seconds())
|
||||
}
|
||||
|
||||
func (m *Manager) now() time.Time {
|
||||
if m != nil && m.Now != nil {
|
||||
return m.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (m *Manager) nowUnix() int64 {
|
||||
return m.now().Unix()
|
||||
}
|
||||
|
||||
func ParseCredentialInput(r io.Reader) (CredentialInput, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
var input CredentialInput
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "protocol":
|
||||
input.Protocol = value
|
||||
case "host":
|
||||
input.Host = value
|
||||
case "path":
|
||||
input.Path = value
|
||||
case "url":
|
||||
u, err := NormalizeGitHTTPURL(value)
|
||||
if err == nil {
|
||||
parsed, _ := parseNormalizedForInput(u)
|
||||
input = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return input, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func parseNormalizedForInput(raw string) (CredentialInput, error) {
|
||||
parts := strings.SplitN(raw, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return CredentialInput{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid credential URL")
|
||||
}
|
||||
hostPath := parts[1]
|
||||
idx := strings.Index(hostPath, "/")
|
||||
if idx < 0 {
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath, Path: "/"}, nil
|
||||
}
|
||||
return CredentialInput{Protocol: parts[0], Host: hostPath[:idx], Path: hostPath[idx:]}, nil
|
||||
}
|
||||
|
||||
func writeGitCredential(w io.Writer, username, pat string) error {
|
||||
if username == "" || pat == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "username=%s\n", username); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "password=%s\n", pat); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return "x-access-token"
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error {
|
||||
if issued == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: empty credential")
|
||||
}
|
||||
if issued.AppID != "" && issued.AppID != appID {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID)
|
||||
}
|
||||
if normalizedURL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL")
|
||||
}
|
||||
if strings.TrimSpace(issued.PAT) == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token")
|
||||
}
|
||||
if issued.ExpiresAt <= now {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response expiredTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
type SecretStore struct {
|
||||
kc keychain.KeychainAccess
|
||||
}
|
||||
|
||||
func NewSecretStore(kc keychain.KeychainAccess) *SecretStore {
|
||||
return &SecretStore{kc: kc}
|
||||
}
|
||||
|
||||
func (s *SecretStore) Get(ref string) (string, error) {
|
||||
if s == nil || ref == "" {
|
||||
return "", nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return "", nil
|
||||
}
|
||||
return s.kc.Get(KeychainService, ref)
|
||||
}
|
||||
|
||||
func (s *SecretStore) Set(ref, pat string) error {
|
||||
if s == nil || s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}}
|
||||
}
|
||||
if ref == "" {
|
||||
return &errs.InternalError{Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "save local Git credential PAT to keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if ref == "" {
|
||||
return nil
|
||||
}
|
||||
if s.kc == nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "local keychain is unavailable",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}}
|
||||
}
|
||||
if err := s.kc.Remove(KeychainService, ref); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 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.
|
||||
// - lockURL is an in-process sync.Mutex that never fails and blocks
|
||||
// indefinitely; holding it while waiting on lockApp would risk
|
||||
// deadlocking with a concurrent goroutine that held lockApp first.
|
||||
//
|
||||
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
|
||||
// lockApp. Get() is the only path that touches per-URL state in addition to
|
||||
// per-app state, so it is the only caller that takes both locks.
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"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"
|
||||
lock := lockfile.New(filepath.Join(dir, filepath.Base(name)))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential metadata is CLI config-dir state, not user file I/O.
|
||||
)
|
||||
|
||||
type AppStorage interface {
|
||||
Read(appID, key string) ([]byte, error)
|
||||
Write(appID, key string, data []byte) error
|
||||
Delete(appID, key string) error
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
appID string
|
||||
storage AppStorage
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{path: filepath.Join(core.GetConfigDir(), MetadataFilename)}
|
||||
}
|
||||
|
||||
func NewAppStore(appID string, storage AppStorage) *Store {
|
||||
return &Store{appID: appID, storage: storage}
|
||||
}
|
||||
|
||||
func NewStoreAt(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Path() string {
|
||||
if s.storage != nil {
|
||||
return fmt.Sprintf("apps:%s/%s", s.appID, MetadataFilename)
|
||||
}
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s *Store) Load() (*CredentialFile, error) {
|
||||
data, err := s.read()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return &CredentialFile{Version: CurrentCredentialVersion}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return &CredentialFile{Version: CurrentCredentialVersion}, nil
|
||||
}
|
||||
var file CredentialFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid %s: %s", MetadataFilename, err).
|
||||
WithHint("the local Git credential metadata is damaged; rerun `lark-cli apps +git-credential-init --app-id <app_id>` after backing up or removing the damaged app metadata").
|
||||
WithCause(err)
|
||||
}
|
||||
if file.Version == 0 {
|
||||
file.Version = CurrentCredentialVersion
|
||||
}
|
||||
if file.Version > CurrentCredentialVersion {
|
||||
return nil, &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: fmt.Sprintf("%s version %d is newer than supported version %d", MetadataFilename, file.Version, CurrentCredentialVersion),
|
||||
Hint: "upgrade lark-cli and retry",
|
||||
}}
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
func (s *Store) Save(file *CredentialFile) error {
|
||||
if file == nil {
|
||||
file = &CredentialFile{}
|
||||
}
|
||||
file.Version = CurrentCredentialVersion
|
||||
data, _ := json.MarshalIndent(file, "", " ")
|
||||
return s.write(append(data, '\n'))
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(record CredentialRecord) error {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.CredentialRecord = record
|
||||
return s.Save(file)
|
||||
}
|
||||
|
||||
func (s *Store) Current() (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &file.CredentialRecord, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteByURL(gitHTTPURL string) (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
record := file.CredentialRecord
|
||||
if err := s.delete(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindByURL(gitHTTPURL string) (*CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &file.CredentialRecord, nil
|
||||
}
|
||||
|
||||
func (s *Store) Records() ([]CredentialRecord, error) {
|
||||
file, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if file.GitHTTPURL == "" {
|
||||
return []CredentialRecord{}, nil
|
||||
}
|
||||
return []CredentialRecord{file.CredentialRecord}, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindByAppID(appID string, profile ProfileContext) ([]CredentialRecord, error) {
|
||||
records, err := s.Records()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CredentialRecord, 0)
|
||||
for _, record := range records {
|
||||
if record.AppID != appID {
|
||||
continue
|
||||
}
|
||||
if profile.Profile != "" && record.Profile != profile.Profile {
|
||||
continue
|
||||
}
|
||||
if profile.ProfileAppID != "" && record.ProfileAppID != profile.ProfileAppID {
|
||||
continue
|
||||
}
|
||||
if profile.UserOpenID != "" && record.UserOpenID != profile.UserOpenID {
|
||||
continue
|
||||
}
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) read() ([]byte, error) {
|
||||
if s.storage != nil {
|
||||
data, err := s.storage.Read(s.appID, MetadataFilename)
|
||||
if data == nil && err == nil {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
return vfs.ReadFile(s.path)
|
||||
}
|
||||
|
||||
func (s *Store) write(data []byte) error {
|
||||
if s.storage != nil {
|
||||
return s.storage.Write(s.appID, MetadataFilename, data)
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(s.path, data, 0600)
|
||||
}
|
||||
|
||||
func (s *Store) delete() error {
|
||||
if s.storage != nil {
|
||||
return s.storage.Delete(s.appID, MetadataFilename)
|
||||
}
|
||||
if err := vfs.Remove(s.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CurrentCredentialVersion = 1
|
||||
MetadataFilename = "git.json"
|
||||
|
||||
// KeychainService intentionally reuses the CLI-wide internal keychain
|
||||
// service, so Git PAT .enc files stay under Application Support/lark-cli.
|
||||
KeychainService = "lark-cli"
|
||||
|
||||
StatusPending = "pending"
|
||||
StatusConfirmed = "confirmed"
|
||||
|
||||
ListStatusValid = "valid"
|
||||
ListStatusExpired = "expired"
|
||||
ListStatusInvalidated = "invalidated"
|
||||
ListStatusMissingSecret = "missing_secret"
|
||||
ListStatusIncomplete = "incomplete"
|
||||
|
||||
refreshBeforeExpiry = 10 * time.Minute
|
||||
eraseCooldown = 5 * time.Minute
|
||||
)
|
||||
|
||||
// CredentialFile is the app-scoped non-secret metadata persisted under the
|
||||
// app storage directory.
|
||||
type CredentialFile struct {
|
||||
Version int `json:"version"`
|
||||
CredentialRecord
|
||||
}
|
||||
|
||||
// CredentialRecord points to the keychain-stored PAT without storing the PAT
|
||||
// plaintext in metadata.
|
||||
type CredentialRecord struct {
|
||||
AppID string `json:"app_id"`
|
||||
GitHTTPURL string `json:"git_http_url"`
|
||||
Profile string `json:"profile"`
|
||||
ProfileAppID string `json:"profile_app_id"`
|
||||
UserOpenID string `json:"user_open_id"`
|
||||
Username string `json:"username"`
|
||||
PATRef string `json:"pat_ref"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
LastEraseAt int64 `json:"last_erase_at,omitempty"`
|
||||
InvalidatedAt int64 `json:"invalidated_at,omitempty"`
|
||||
}
|
||||
|
||||
type IssuedCredential struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Username string
|
||||
PAT string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
type InitResult struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Refreshed bool
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type RemoveResult struct {
|
||||
AppID string
|
||||
Removed bool
|
||||
Records []CredentialRecord
|
||||
ConfigWarning string
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Records []ListRecord
|
||||
}
|
||||
|
||||
type ListRecord struct {
|
||||
AppID string
|
||||
GitHTTPURL string
|
||||
Status string
|
||||
ExpiresAt int64
|
||||
UpdatedAt int64
|
||||
Profile string
|
||||
ProfileAppID string
|
||||
UserOpenID string
|
||||
Expired bool
|
||||
InvalidatedAt int64
|
||||
}
|
||||
|
||||
type CredentialInput struct {
|
||||
Protocol string
|
||||
Host string
|
||||
Path string
|
||||
}
|
||||
|
||||
type ProfileContext struct {
|
||||
Profile string
|
||||
ProfileAppID string
|
||||
UserOpenID string
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func NormalizeGitHTTPURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err)
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
|
||||
func NormalizeCredentialInput(input CredentialInput) (string, error) {
|
||||
protocol := strings.TrimSpace(input.Protocol)
|
||||
host := strings.TrimSpace(input.Host)
|
||||
if protocol == "" || host == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential input must include protocol and host")
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: protocol,
|
||||
Host: host,
|
||||
Path: input.Path,
|
||||
}
|
||||
return normalizeParsedURL(u)
|
||||
}
|
||||
|
||||
func normalizeParsedURL(u *url.URL) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs")
|
||||
}
|
||||
host := normalizeHost(scheme, u.Host)
|
||||
if host == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url host is empty")
|
||||
}
|
||||
cleanPath := cleanURLPath(u.EscapedPath())
|
||||
normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String()
|
||||
if normalized != scheme+"://"+host+"/" {
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeHost(scheme, host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
name, port, err := net.SplitHostPort(host)
|
||||
if err == nil {
|
||||
if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
|
||||
return normalizeHostname(name)
|
||||
}
|
||||
return net.JoinHostPort(strings.ToLower(name), port)
|
||||
}
|
||||
return normalizeHostname(host)
|
||||
}
|
||||
|
||||
func normalizeHostname(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
name := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
if ip := net.ParseIP(name); ip != nil && ip.To4() == nil {
|
||||
return joinHostWithoutPort(name)
|
||||
}
|
||||
return host
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
|
||||
return joinHostWithoutPort(host)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func joinHostWithoutPort(host string) string {
|
||||
joined := net.JoinHostPort(host, "")
|
||||
return strings.TrimSuffix(joined, ":")
|
||||
}
|
||||
|
||||
func cleanURLPath(rawPath string) string {
|
||||
if rawPath == "" {
|
||||
return "/"
|
||||
}
|
||||
decoded, err := url.PathUnescape(rawPath)
|
||||
if err != nil {
|
||||
decoded = rawPath
|
||||
}
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
return path.Clean(decoded)
|
||||
}
|
||||
|
||||
func BuildPATRef(profile ProfileContext, appID string) string {
|
||||
seed := fmt.Sprintf("%s\x00%s", profile.UserOpenID, appID)
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
return "app-git-pat:" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
Reference in New Issue
Block a user