R-616 (on top of R-615): the catalog clone stores no credentials
Replaces 365eff6 for main, which now carries R-615 (0b349e2). The plain
URL is cloned; the Basic credentials ride per git command as
http.<origin>.extraHeader via GIT_CONFIG_COUNT. R-615's origin compare
now compares credential-free forms against the configured URL, so a set
token never re-clones; a stored origin with user:token@ is rewritten
without it. Pinned by TestR616_* incl. TestR616_TokenSetSameRepoNoRecloneOriginClean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -300,7 +301,10 @@ func (s *Syncer) gitCloneOrPull() error {
|
||||
// Clone
|
||||
s.logger.Printf("[INFO] [sync] Cloning %s → %s", s.cfg.Git.RepoURL, s.cacheDir)
|
||||
args := []string{"clone", "--depth", "1", "--branch", s.cfg.Git.Branch}
|
||||
repoURL := s.buildRepoURL()
|
||||
// R-616: clone the PLAIN URL — git persists the clone URL as `origin`, so a credentialed URL
|
||||
// left the token readable in .git/config and printed by any `git remote -v`. The credentials
|
||||
// ride per command in the environment instead (gitAuthEnv).
|
||||
repoURL := s.cfg.Git.RepoURL
|
||||
args = append(args, repoURL, s.cacheDir)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [sync] git clone URL: %s, branch: %s, cacheDir: %s", maskRepoURL(repoURL), s.cfg.Git.Branch, s.cacheDir)
|
||||
@@ -312,22 +316,25 @@ func (s *Syncer) gitCloneOrPull() error {
|
||||
s.removeGitLockFiles()
|
||||
|
||||
// R-615: the clone remembers the repository it was made from. A changed `git.repo_url` used to be
|
||||
// INERT — every later fetch went to the stored origin and reported success. Compare and follow:
|
||||
// a different repository (credentials aside) → drop the cache and clone the new one; the same
|
||||
// repository with different credentials (a rotated token) → point origin at the new URL.
|
||||
// INERT — every later fetch went to the stored origin and reported success. Compare and follow: a
|
||||
// different repository (credentials aside) → drop the cache and clone the new one.
|
||||
// R-616: credentials are NEVER part of the stored origin (they ride per command, gitAuthEnv), so the
|
||||
// comparison is between credential-free forms, and a stored origin that still carries a
|
||||
// `user:token@` (a clone made before R-616) is rewritten without it. A rotated token therefore needs
|
||||
// no origin change at all. Pinned by TestR616_TokenSetSameRepoNoRecloneOriginClean.
|
||||
if cur, err := s.gitOutput(s.cacheDir, "config", "--get", "remote.origin.url"); err != nil {
|
||||
s.logger.Printf("[WARN] [sync] cannot read the catalog cache's origin (%v) — fetching from it as before", err)
|
||||
} else if want := s.buildRepoURL(); cur != want {
|
||||
if stripURLCreds(cur) != stripURLCreds(want) {
|
||||
s.logger.Printf("[WARN] [sync] git.repo_url changed (cache was cloned from %s, config says %s) — re-cloning the catalog cache from the configured repository (R-615)", maskRepoURL(cur), maskRepoURL(want))
|
||||
if err := os.RemoveAll(s.cacheDir); err != nil {
|
||||
return fmt.Errorf("removing the catalog cache for a re-clone: %w", err)
|
||||
}
|
||||
return s.gitCloneOrPull()
|
||||
} else if want := s.cfg.Git.RepoURL; stripURLCreds(cur) != stripURLCreds(want) {
|
||||
s.logger.Printf("[WARN] [sync] git.repo_url changed (cache was cloned from %s, config says %s) — re-cloning the catalog cache from the configured repository (R-615)", maskRepoURL(cur), maskRepoURL(want))
|
||||
if err := os.RemoveAll(s.cacheDir); err != nil {
|
||||
return fmt.Errorf("removing the catalog cache for a re-clone: %w", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] [sync] catalog repository credentials changed — updating the cache's origin (R-615)")
|
||||
if err := s.gitCmd(s.cacheDir, "remote", "set-url", "origin", want); err != nil {
|
||||
return fmt.Errorf("git remote set-url: %w", err)
|
||||
return s.gitCloneOrPull()
|
||||
} else if clean := stripURLCreds(cur); clean != cur {
|
||||
if err := s.gitCmd(s.cacheDir, "remote", "set-url", "origin", clean); err != nil {
|
||||
s.logger.Printf("[WARN] [sync] could not remove stored credentials from the catalog clone origin (%s): %v", maskRepoURL(cur), err)
|
||||
} else {
|
||||
s.logger.Printf("[INFO] [sync] removed stored credentials from the catalog clone origin (R-616): now %s", clean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +369,50 @@ func (s *Syncer) removeGitLockFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
// buildRepoURL constructs the repo URL with optional auth credentials.
|
||||
func (s *Syncer) buildRepoURL() string {
|
||||
url := s.cfg.Git.RepoURL
|
||||
if s.cfg.Git.Username != "" && s.cfg.Git.Token != "" {
|
||||
// Inject credentials into HTTPS URL: https://user:token@host/path
|
||||
url = strings.Replace(url, "https://", fmt.Sprintf("https://%s:%s@", s.cfg.Git.Username, s.cfg.Git.Token), 1)
|
||||
// gitAuthEnv returns the environment that authenticates one git command, or nil when no credentials
|
||||
// are configured or the remote is not HTTPS.
|
||||
//
|
||||
// R-616: the credentials used to be injected into the clone URL (https://user:token@host/…), which
|
||||
// git then stored as the clone's `origin` — a plaintext token in <data>/catalog-cache/.git/config,
|
||||
// printed by every `git remote -v`. They now travel as an `http.<scheme://host/>.extraHeader`
|
||||
// carrying the same HTTP Basic credentials the URL form sent, set through GIT_CONFIG_COUNT (git ≥
|
||||
// 2.31; the runtime image is bookworm, 2.39). The environment is neither persisted by git nor in
|
||||
// the process argv nor in any log line here. The header is scoped to the remote's own origin so a
|
||||
// redirect to another host never receives it. Pinned by TestR616_CloneStoresNoCredentials.
|
||||
func (s *Syncer) gitAuthEnv() []string {
|
||||
u, p := s.cfg.Git.Username, s.cfg.Git.Token
|
||||
if u == "" || p == "" {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
origin := httpsOrigin(s.cfg.Git.RepoURL)
|
||||
if origin == "" {
|
||||
return nil
|
||||
}
|
||||
basic := base64.StdEncoding.EncodeToString([]byte(u + ":" + p))
|
||||
return []string{
|
||||
"GIT_CONFIG_COUNT=1",
|
||||
"GIT_CONFIG_KEY_0=http." + origin + ".extraHeader",
|
||||
"GIT_CONFIG_VALUE_0=Authorization: Basic " + basic,
|
||||
}
|
||||
}
|
||||
|
||||
// httpsOrigin returns "https://host[:port]/" for an https URL (userinfo dropped), or "" otherwise.
|
||||
func httpsOrigin(raw string) string {
|
||||
rest, ok := strings.CutPrefix(raw, "https://")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
host := rest
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
host = rest[:i]
|
||||
}
|
||||
if i := strings.LastIndexByte(host, '@'); i >= 0 {
|
||||
host = host[i+1:]
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://" + host + "/"
|
||||
}
|
||||
|
||||
// copyTemplates copies docker-compose.yml and .felhom.yml from the catalog cache
|
||||
@@ -696,6 +739,9 @@ func (s *Syncer) runGitInDir(ctx context.Context, dir string, args ...string) er
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
if env := s.gitAuthEnv(); env != nil {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = io.Discard
|
||||
|
||||
Reference in New Issue
Block a user