Skip to content

Commit 5e09eb4

Browse files
gustavobertoiclaude
andcommitted
fix(git): apply M3 adversarial-review findings (9 confirmed)
Security/robustness: - WithToken now resets the inherited credential.helper (`-c credential.helper=`) so a token clone never persists the token to disk (e.g. ~/.git-credentials via the `store` helper), and places the GIT_ASKPASS shim under the XDG runtime dir (exec-permitting) instead of a possibly-noexec /tmp. - hardenedEnv sets `GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10` when no TTY is attached (and not preset), so an unknown host key or a passphrase-protected key with no agent fails fast per-repo instead of hanging the parallel batch (acceptance #6) — still honoring ~/.ssh/config + ProxyJump. Correctness: - `ws clone` validates the existing remote with git.SameRemote (ssh/https/ shorthand equivalence) and treats an unreadable origin as a per-repo failure rather than a silent pass. - `ws git` now follows the spec grammar `git <args> -- [names]` (ArgsLenAtDash) and supports the repo-name subset. - status table: show the conflicts count when non-zero; render AHEAD/BEHIND as "-" for an upstream-gone branch instead of a misleading "+0/-0". - ExpandURL trims the repo:/shorthand value (no leaked leading space). New tests: SameRemote equivalence, credential.helper reset, URL trimming. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8581975 commit 5e09eb4

8 files changed

Lines changed: 185 additions & 23 deletions

File tree

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/moby/moby/client v0.4.1
1616
github.com/spf13/cobra v1.10.2
1717
golang.org/x/sync v0.20.0
18+
golang.org/x/term v0.44.0
1819
modernc.org/sqlite v1.52.0
1920
)
2021

@@ -76,7 +77,7 @@ require (
7677
go.opentelemetry.io/otel/trace v1.35.0 // indirect
7778
go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect
7879
golang.org/x/crypto v0.52.0 // indirect
79-
golang.org/x/sys v0.45.0 // indirect
80+
golang.org/x/sys v0.46.0 // indirect
8081
golang.org/x/text v0.37.0 // indirect
8182
modernc.org/libc v1.72.3 // indirect
8283
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
172172
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
173173
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
174174
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
175-
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
176-
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
175+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
176+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
177+
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
178+
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
177179
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
178180
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
179181
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=

internal/cli/ws.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ func branchLabel(s *git.Status) string {
193193
}
194194

195195
func abLabel(s *git.Status) string {
196-
if s.Upstream == "" {
196+
// No upstream, or the upstream was deleted: ahead/behind is unknown.
197+
if s.Upstream == "" || s.UpstreamGone {
197198
return "-"
198199
}
199200
return fmt.Sprintf("+%d/-%d", s.Ahead, s.Behind)
@@ -203,6 +204,10 @@ func stateLabel(s *git.Status) string {
203204
if !s.Dirty() {
204205
return "clean"
205206
}
207+
if s.Conflicts > 0 {
208+
return fmt.Sprintf("dirty (%d staged, %d unstaged, %d untracked, %d conflicts)",
209+
s.Staged, s.Unstaged, s.Untracked, s.Conflicts)
210+
}
206211
return fmt.Sprintf("dirty (%d staged, %d unstaged, %d untracked)", s.Staged, s.Unstaged, s.Untracked)
207212
}
208213

@@ -230,11 +235,17 @@ func newWsCloneCmd(g *GlobalOpts) *cobra.Command {
230235
return fmt.Errorf("no git URL declared")
231236
}
232237
if gx.IsRepo(ctx, r.dir) {
233-
// Idempotent: validate the remote matches the expected URL.
234-
if cur, err := gx.RemoteURL(ctx, r.dir); err == nil && cur != r.url {
238+
// Idempotent: validate the remote matches (tolerating
239+
// ssh/https/shorthand equivalence). A failure to read origin is
240+
// a per-repo error, not a silent pass.
241+
cur, err := gx.RemoteURL(ctx, r.dir)
242+
if err != nil {
243+
return fmt.Errorf("exists but cannot read its origin remote: %w", err)
244+
}
245+
if !git.SameRemote(cur, r.url) {
235246
return fmt.Errorf("exists but origin is %q, expected %q", cur, r.url)
236247
}
237-
return nil // already cloned
248+
return nil // already cloned, correct remote
238249
}
239250
return gx.Clone(ctx, r.url, r.dir, git.CloneOptions{})
240251
})
@@ -285,13 +296,19 @@ func newWsSyncCmd(g *GlobalOpts) *cobra.Command {
285296
func newWsGitCmd(g *GlobalOpts) *cobra.Command {
286297
var jobs int
287298
cmd := &cobra.Command{
288-
Use: "git -- <git args...>",
289-
Short: "Run an arbitrary git command across every repo",
299+
Use: "git <git args...> [-- names...]",
300+
Short: "Run an arbitrary git command across repos (subset after --)",
290301
RunE: func(cmd *cobra.Command, args []string) error {
291-
if len(args) == 0 {
292-
return fmt.Errorf("usage: ws git -- <git args...>")
302+
// Grammar: `ws git <git args...> -- [all|name...]`. Everything before
303+
// the -- is the git command; names after it select a repo subset.
304+
gitArgs, names := args, []string(nil)
305+
if dash := cmd.ArgsLenAtDash(); dash >= 0 {
306+
gitArgs, names = args[:dash], args[dash:]
307+
}
308+
if len(gitArgs) == 0 {
309+
return fmt.Errorf("usage: %s ws git <git args...> [-- names...]", rootName(cmd))
293310
}
294-
repos, err := loadRepos(nil)
311+
repos, err := loadRepos(names)
295312
if err != nil {
296313
return err
297314
}
@@ -306,7 +323,7 @@ func newWsGitCmd(g *GlobalOpts) *cobra.Command {
306323
if !gx.IsRepo(ctx, r.dir) {
307324
return fmt.Errorf("not cloned")
308325
}
309-
return gx.Run(ctx, r.dir, args...)
326+
return gx.Run(ctx, r.dir, gitArgs...)
310327
})
311328
return reportResults(cmd, g, "git", results)
312329
},

internal/git/askpass.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,33 @@ import (
55
"os"
66
"path/filepath"
77
"strings"
8+
9+
"github.com/open-source-cloud/devstack/internal/xdg"
810
)
911

1012
// WithToken returns a copy of the Git handle that authenticates HTTPS remotes
1113
// with token via a generated GIT_ASKPASS shim, plus a cleanup func that removes
12-
// the shim. The token is written to a 0600 file in a private temp dir and never
13-
// embedded in the URL or .git/config or visible in `ps` (spec 06). The secrets
14-
// provider supplies the token (M4); this is the injection mechanism.
14+
// the shim. The token is written to a 0600 file in a private dir and never
15+
// embedded in the URL, .git/config, or visible in `ps` (spec 06). It also
16+
// disables any inherited credential.helper for these invocations, so the token
17+
// is NOT persisted to disk (e.g. ~/.git-credentials by the `store` helper) after
18+
// a successful clone. The secrets provider supplies the token (M4); this is the
19+
// injection mechanism.
1520
func (g *Git) WithToken(token string) (*Git, func(), error) {
1621
if token == "" {
1722
return g, func() {}, nil
1823
}
19-
dir, err := os.MkdirTemp("", "devstack-askpass-")
24+
// Place the shim under the XDG runtime dir (typically an exec-permitting
25+
// tmpfs) rather than /tmp, which may be mounted noexec on hardened systems
26+
// (GIT_ASKPASS must be executable). Fall back to the default temp dir if the
27+
// runtime dir is unavailable.
28+
base := xdg.RuntimeDir()
29+
if base != "" {
30+
if err := os.MkdirAll(base, 0o700); err != nil {
31+
base = ""
32+
}
33+
}
34+
dir, err := os.MkdirTemp(base, "devstack-askpass-")
2035
if err != nil {
2136
return nil, nil, fmt.Errorf("create askpass dir: %w", err)
2237
}
@@ -37,7 +52,14 @@ func (g *Git) WithToken(token string) (*Git, func(), error) {
3752
return nil, nil, fmt.Errorf("write askpass shim: %w", err)
3853
}
3954

40-
cp := &Git{bin: g.bin, env: append(append([]string{}, g.env...), "GIT_ASKPASS="+shim)}
55+
cp := &Git{
56+
bin: g.bin,
57+
env: append(append([]string{}, g.env...), "GIT_ASKPASS="+shim),
58+
// Reset the inherited credential.helper list so a successful clone's
59+
// `git credential approve` has no helper to persist the token to
60+
// (e.g. the plaintext `store` helper writing ~/.git-credentials).
61+
configArgs: append(append([]string{}, g.configArgs...), "-c", "credential.helper="),
62+
}
4163
return cp, cleanup, nil
4264
}
4365

internal/git/askpass_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,28 @@ func TestWithTokenShim(t *testing.T) {
4444
t.Errorf("token file perms = %v, want 0600", perm)
4545
}
4646

47+
// The token handle disables inherited credential persistence so a successful
48+
// clone never writes the token to ~/.git-credentials (the `store` helper).
49+
if !containsPair(tg.configArgs, "-c", "credential.helper=") {
50+
t.Errorf("token handle should reset credential.helper, got configArgs=%v", tg.configArgs)
51+
}
52+
4753
// cleanup removes the whole shim dir.
4854
cleanup()
4955
if _, err := os.Stat(askpass); !os.IsNotExist(err) {
5056
t.Error("cleanup should remove the shim")
5157
}
5258
}
5359

60+
func containsPair(s []string, a, b string) bool {
61+
for i := 0; i+1 < len(s); i++ {
62+
if s[i] == a && s[i+1] == b {
63+
return true
64+
}
65+
}
66+
return false
67+
}
68+
5469
func TestWithTokenEmptyIsNoop(t *testing.T) {
5570
g := testGit(t)
5671
g2, cleanup, err := g.WithToken("")

internal/git/gitx.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"os/exec"
1616
"strconv"
1717
"strings"
18+
19+
"golang.org/x/term"
1820
)
1921

2022
// MinVersion is the git floor (spec 06). 2.30 is conservative (porcelain=v2
@@ -25,6 +27,9 @@ var MinVersion = Version{2, 30}
2527
type Git struct {
2628
bin string
2729
env []string
30+
// configArgs are `-c key=val` pairs prepended to every invocation (used by
31+
// WithToken to disable inherited credential persistence).
32+
configArgs []string
2833
}
2934

3035
// Version is a major.minor pair for the git floor check.
@@ -58,19 +63,45 @@ func New() (*Git, error) {
5863
}
5964

6065
// hardenedEnv augments the process env so git never blocks on an interactive
61-
// prompt and always emits stable, parseable output (spec 06).
66+
// prompt and always emits stable, parseable output (spec 06). GIT_TERMINAL_PROMPT
67+
// and GCM_INTERACTIVE cover git's own prompts and Git Credential Manager (the
68+
// HTTPS path); for the SSH path, BatchMode is added when NO terminal is attached
69+
// so an unknown host key or a passphrase-protected key with no agent fails fast
70+
// per-repo instead of hanging the parallel batch — while still honoring
71+
// ~/.ssh/config (IdentityFile, Host aliases, ProxyJump) and known_hosts. When a
72+
// TTY IS attached, interactive passphrase/host-key entry is left intact, and a
73+
// pre-existing GIT_SSH_COMMAND is always respected.
6274
func hardenedEnv(base []string) []string {
63-
return append(base,
75+
env := append(base,
6476
"GIT_TERMINAL_PROMPT=0", // never prompt on the terminal
6577
"GCM_INTERACTIVE=never", // Git Credential Manager: never pop UI
6678
"LC_ALL=C", // stable, parseable, locale-independent output
6779
)
80+
if !hasEnv(base, "GIT_SSH_COMMAND") && !term.IsTerminal(int(os.Stderr.Fd())) {
81+
env = append(env, "GIT_SSH_COMMAND=ssh -o BatchMode=yes -o ConnectTimeout=10")
82+
}
83+
return env
84+
}
85+
86+
// hasEnv reports whether env already defines key.
87+
func hasEnv(env []string, key string) bool {
88+
prefix := key + "="
89+
for _, e := range env {
90+
if strings.HasPrefix(e, prefix) {
91+
return true
92+
}
93+
}
94+
return false
6895
}
6996

7097
// run executes git in dir (empty = inherit CWD), capturing stdout. A failure is
7198
// wrapped with the args + captured stderr so it is self-debuggable (§7.6).
7299
func (g *Git) run(ctx context.Context, dir string, args ...string) ([]byte, error) {
73-
cmd := exec.CommandContext(ctx, g.bin, args...)
100+
full := args
101+
if len(g.configArgs) > 0 {
102+
full = append(append([]string{}, g.configArgs...), args...)
103+
}
104+
cmd := exec.CommandContext(ctx, g.bin, full...)
74105
cmd.Dir = dir
75106
cmd.Env = g.env
76107
var stdout, stderr strings.Builder

internal/git/url.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,63 @@ func ExpandURL(spec string) string {
2626
}
2727
// Explicit passthrough.
2828
if rest, ok := strings.CutPrefix(spec, "repo:"); ok {
29-
return rest
29+
return strings.TrimSpace(rest)
3030
}
3131
// Already a full URL or scp-like remote or a path.
3232
if looksLikeURL(spec) {
3333
return spec
3434
}
3535
if scheme, rest, ok := strings.Cut(spec, ":"); ok {
3636
if host, known := knownHosts[scheme]; known {
37-
path := strings.TrimSuffix(rest, ".git")
37+
path := strings.TrimSuffix(strings.TrimSpace(rest), ".git")
3838
return "git@" + host + ":" + path + ".git"
3939
}
4040
}
4141
return spec
4242
}
4343

44+
// SameRemote reports whether two git remote specs point at the same repository,
45+
// tolerating ssh/https/scp/shorthand differences (e.g. git@github.com:a/b.git
46+
// and https://github.com/a/b are the same). Used by `ws clone` so an existing
47+
// clone reached via a different transport is not flagged as a URL mismatch.
48+
func SameRemote(a, b string) bool {
49+
ah, ap := canonicalRemote(a)
50+
bh, bp := canonicalRemote(b)
51+
return ah == bh && ap == bp
52+
}
53+
54+
// canonicalRemote reduces a remote spec to a (host, path) identity. A purely
55+
// local path has an empty host and its cleaned path.
56+
func canonicalRemote(s string) (host, path string) {
57+
s = ExpandURL(strings.TrimSpace(s))
58+
switch {
59+
case strings.Contains(s, "://"):
60+
s = s[strings.Index(s, "://")+3:]
61+
if at := strings.IndexByte(s, '@'); at >= 0 {
62+
if slash := strings.IndexByte(s, '/'); slash < 0 || at < slash {
63+
s = s[at+1:]
64+
}
65+
}
66+
host, path, _ = strings.Cut(s, "/")
67+
host, _, _ = strings.Cut(host, ":") // drop :port
68+
case strings.HasPrefix(s, "/"), strings.HasPrefix(s, "."), strings.HasPrefix(s, "~"):
69+
return "", strings.TrimSuffix(s, ".git") // local path
70+
default:
71+
// scp-style git@host:path
72+
if at := strings.IndexByte(s, '@'); at >= 0 {
73+
s = s[at+1:]
74+
}
75+
if h, p, ok := strings.Cut(s, ":"); ok {
76+
host, path = h, p
77+
} else {
78+
return "", strings.TrimSuffix(s, ".git")
79+
}
80+
}
81+
host = strings.ToLower(host)
82+
path = strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git")
83+
return host, path
84+
}
85+
4486
// looksLikeURL reports whether spec is already a transport URL, an scp-style
4587
// remote (git@host:path), or a filesystem path.
4688
func looksLikeURL(spec string) bool {

internal/git/url_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,35 @@ func TestExpandURL(t *testing.T) {
2121
}
2222
}
2323
}
24+
25+
func TestExpandURLTrimsRepoPassthrough(t *testing.T) {
26+
if got := ExpandURL("repo: git@host:acme/x.git"); got != "git@host:acme/x.git" {
27+
t.Errorf("repo: passthrough leaked whitespace: %q", got)
28+
}
29+
if got := ExpandURL("github: acme/api"); got != "git@github.com:acme/api.git" {
30+
t.Errorf("shorthand leaked whitespace: %q", got)
31+
}
32+
}
33+
34+
func TestSameRemote(t *testing.T) {
35+
same := [][2]string{
36+
{"git@github.com:acme/api.git", "https://github.com/acme/api"},
37+
{"git@github.com:acme/api.git", "https://github.com/acme/api.git"},
38+
{"github:acme/api", "https://github.com/acme/api"},
39+
{"ssh://git@github.com:22/acme/api.git", "git@github.com:acme/api.git"},
40+
}
41+
for _, p := range same {
42+
if !SameRemote(p[0], p[1]) {
43+
t.Errorf("SameRemote(%q, %q) = false, want true", p[0], p[1])
44+
}
45+
}
46+
diff := [][2]string{
47+
{"git@github.com:acme/api.git", "git@github.com:acme/web.git"},
48+
{"git@github.com:acme/api.git", "git@gitlab.com:acme/api.git"},
49+
}
50+
for _, p := range diff {
51+
if SameRemote(p[0], p[1]) {
52+
t.Errorf("SameRemote(%q, %q) = true, want false", p[0], p[1])
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)