Skip to content

Commit ea25253

Browse files
Merge branch 'main' into 74-git-submodule-support
2 parents 3f81838 + 0134c3b commit ea25253

11 files changed

Lines changed: 202 additions & 66 deletions

File tree

docs/git-auth.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
1-
# Git Authentication
1+
# Supported URL Formats
2+
3+
Envbuilder supports three distinct types of Git URLs:
4+
5+
1) Valid URLs with scheme (e.g. `https://user:password@host.tld:12345/path/to/repo`)
6+
2) SCP-like URLs (e.g. `git@host.tld:path/to/repo.git`)
7+
3) Filesystem URLs (require the `git` executable to be available)
8+
9+
Based on the type of URL, one of two authentication methods will be used:
10+
11+
| Git URL format | GIT_USERNAME | GIT_PASSWORD | Auth Method |
12+
| ------------------------|--------------|--------------|-------------|
13+
| https?://host.tld/repo | Not Set | Not Set | None |
14+
| https?://host.tld/repo | Not Set | Set | HTTP Basic |
15+
| https?://host.tld/repo | Set | Not Set | HTTP Basic |
16+
| https?://host.tld/repo | Set | Set | HTTP Basic |
17+
| ssh://host.tld/repo | - | - | SSH |
18+
| git://host.tld/repo | - | - | SSH |
19+
| file://path/to/repo | - | - | None |
20+
| path/to/repo | - | - | None |
21+
| user@host.tld:path/repo | - | - | SSH |
22+
| All other formats | - | - | SSH |
23+
24+
# Authentication Methods
225

326
Two methods of authentication are supported:
427

git/git.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import (
1212
"path"
1313
"strings"
1414

15+
"github.com/coder/envbuilder/internal/ebutil"
1516
"github.com/coder/envbuilder/options"
1617

17-
giturls "github.com/chainguard-dev/git-urls"
1818
"github.com/go-git/go-billy/v5"
1919
"github.com/go-git/go-git/v5"
2020
"github.com/go-git/go-git/v5/config"
@@ -53,18 +53,17 @@ type CloneRepoOptions struct {
5353
//
5454
// The bool returned states whether the repository was cloned or not.
5555
func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOptions) (bool, error) {
56-
parsed, err := giturls.Parse(opts.RepoURL)
56+
parsed, err := ebutil.ParseRepoURL(opts.RepoURL)
5757
if err != nil {
5858
return false, fmt.Errorf("parse url %q: %w", opts.RepoURL, err)
5959
}
60-
logf("Parsed Git URL as %q", parsed.Redacted())
6160

6261
thinPack := true
6362

6463
if !opts.ThinPack {
6564
thinPack = false
6665
logf("ThinPack options is false, Marking thin-pack as unsupported")
67-
} else if parsed.Hostname() == "dev.azure.com" {
66+
} else if parsed.Host == "dev.azure.com" {
6867
// Azure DevOps requires capabilities multi_ack / multi_ack_detailed,
6968
// which are not fully implemented and by default are included in
7069
// transport.UnsupportedCapabilities.
@@ -96,12 +95,9 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
9695
if err != nil {
9796
return false, fmt.Errorf("mkdir %q: %w", opts.Path, err)
9897
}
99-
reference := parsed.Fragment
100-
if reference == "" && opts.SingleBranch {
101-
reference = "refs/heads/main"
98+
if parsed.Reference == "" && opts.SingleBranch {
99+
parsed.Reference = "refs/heads/main"
102100
}
103-
parsed.RawFragment = ""
104-
parsed.Fragment = ""
105101
fs, err := opts.Storage.Chroot(opts.Path)
106102
if err != nil {
107103
return false, fmt.Errorf("chroot %q: %w", opts.Path, err)
@@ -123,11 +119,11 @@ func CloneRepo(ctx context.Context, logf func(string, ...any), opts CloneRepoOpt
123119
return false, nil
124120
}
125121

126-
repo, err = git.CloneContext(ctx, gitStorage, fs, &git.CloneOptions{
127-
URL: parsed.String(),
122+
_, err = git.CloneContext(ctx, gitStorage, fs, &git.CloneOptions{
123+
URL: parsed.Cleaned,
128124
Auth: opts.RepoAuth,
129125
Progress: opts.Progress,
130-
ReferenceName: plumbing.ReferenceName(reference),
126+
ReferenceName: plumbing.ReferenceName(parsed.Reference),
131127
InsecureSkipTLS: opts.Insecure,
132128
Depth: opts.Depth,
133129
SingleBranch: opts.SingleBranch,
@@ -258,18 +254,23 @@ func LogHostKeyCallback(logger func(string, ...any)) gossh.HostKeyCallback {
258254
// If SSH_KNOWN_HOSTS is not set, the SSH auth method will be configured
259255
// to accept and log all host keys. Otherwise, host key checking will be
260256
// performed as usual.
257+
//
258+
// Git URL formats may only consist of the following:
259+
// 1. A valid URL with a scheme
260+
// 2. An SCP-like URL (e.g. git@host.tld:path/to/repo.git)
261+
// 3. Local filesystem paths (require `git` executable)
261262
func SetupRepoAuth(logf func(string, ...any), options *options.Options) transport.AuthMethod {
262263
if options.GitURL == "" {
263264
logf("❔ No Git URL supplied!")
264265
return nil
265266
}
266-
parsedURL, err := giturls.Parse(options.GitURL)
267+
parsedURL, err := ebutil.ParseRepoURL(options.GitURL)
267268
if err != nil {
268269
logf("❌ Failed to parse Git URL: %s", err.Error())
269270
return nil
270271
}
271272

272-
if parsedURL.Scheme == "http" || parsedURL.Scheme == "https" {
273+
if parsedURL.Protocol == "http" || parsedURL.Protocol == "https" {
273274
// Special case: no auth
274275
if options.GitUsername == "" && options.GitPassword == "" {
275276
logf("👤 Using no authentication!")
@@ -285,7 +286,7 @@ func SetupRepoAuth(logf func(string, ...any), options *options.Options) transpor
285286
}
286287
}
287288

288-
if parsedURL.Scheme == "file" {
289+
if parsedURL.Protocol == "file" {
289290
// go-git will try to fallback to using the `git` command for local
290291
// filesystem clones. However, it's more likely than not that the
291292
// `git` command is not present in the container image. Log a warning

git/git_test.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import (
44
"context"
55
"crypto/ed25519"
66
"encoding/base64"
7-
"fmt"
87
"io"
98
"net/http/httptest"
109
"net/url"
1110
"os"
1211
"path/filepath"
13-
"regexp"
12+
"strings"
1413
"testing"
1514

1615
"github.com/coder/envbuilder/git"
@@ -76,6 +75,18 @@ func TestCloneRepo(t *testing.T) {
7675
password: "",
7776
expectClone: true,
7877
},
78+
{
79+
name: "whitespace in URL",
80+
mungeURL: func(u *string) {
81+
if u == nil {
82+
t.Errorf("expected non-nil URL")
83+
return
84+
}
85+
*u = " " + *u + " "
86+
t.Logf("munged URL: %q", *u)
87+
},
88+
expectClone: true,
89+
},
7990
} {
8091
tc := tc
8192
t.Run(tc.name, func(t *testing.T) {
@@ -87,6 +98,9 @@ func TestCloneRepo(t *testing.T) {
8798
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
8899
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
89100
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))
101+
if tc.mungeURL != nil {
102+
tc.mungeURL(&srv.URL)
103+
}
90104
clientFS := memfs.New()
91105
// A repo already exists!
92106
_ = gittest.NewRepo(t, clientFS)
@@ -106,6 +120,9 @@ func TestCloneRepo(t *testing.T) {
106120
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
107121
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
108122
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))
123+
if tc.mungeURL != nil {
124+
tc.mungeURL(&srv.URL)
125+
}
109126
clientFS := memfs.New()
110127

111128
cloned, err := git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
@@ -129,7 +146,7 @@ func TestCloneRepo(t *testing.T) {
129146
require.Equal(t, "Hello, world!", readme)
130147
gitConfig := mustRead(t, clientFS, "/workspace/.git/config")
131148
// Ensure we do not modify the git URL that folks pass in.
132-
require.Regexp(t, fmt.Sprintf(`(?m)^\s+url\s+=\s+%s\s*$`, regexp.QuoteMeta(srv.URL)), gitConfig)
149+
require.Contains(t, gitConfig, strings.TrimSpace(srv.URL))
133150
})
134151

135152
// In-URL-style auth e.g. http://user:password@host:port
@@ -139,15 +156,19 @@ func TestCloneRepo(t *testing.T) {
139156
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "README.md", "Hello, world!", "Wow!"))
140157
authMW := mwtest.BasicAuthMW(tc.srvUsername, tc.srvPassword)
141158
srv := httptest.NewServer(authMW(gittest.NewServer(srvFS)))
142-
143159
authURL, err := url.Parse(srv.URL)
144160
require.NoError(t, err)
145161
authURL.User = url.UserPassword(tc.username, tc.password)
162+
cloneURL := authURL.String()
163+
if tc.mungeURL != nil {
164+
tc.mungeURL(&cloneURL)
165+
}
166+
146167
clientFS := memfs.New()
147168

148169
cloned, err := git.CloneRepo(context.Background(), t.Logf, git.CloneRepoOptions{
149170
Path: "/workspace",
150-
RepoURL: authURL.String(),
171+
RepoURL: cloneURL,
151172
Storage: clientFS,
152173
})
153174
require.Equal(t, tc.expectClone, cloned)
@@ -162,7 +183,7 @@ func TestCloneRepo(t *testing.T) {
162183
require.Equal(t, "Hello, world!", readme)
163184
gitConfig := mustRead(t, clientFS, "/workspace/.git/config")
164185
// Ensure we do not modify the git URL that folks pass in.
165-
require.Regexp(t, fmt.Sprintf(`(?m)^\s+url\s+=\s+%s\s*$`, regexp.QuoteMeta(authURL.String())), gitConfig)
186+
require.Contains(t, gitConfig, strings.TrimSpace(cloneURL))
166187
})
167188
})
168189
}
@@ -238,10 +259,9 @@ func TestShallowCloneRepo(t *testing.T) {
238259
func TestCloneRepoSSH(t *testing.T) {
239260
t.Parallel()
240261

241-
t.Run("AuthSuccess", func(t *testing.T) {
262+
t.Run("Success", func(t *testing.T) {
242263
t.Parallel()
243264

244-
// TODO: test the rest of the cloning flow. This just tests successful auth.
245265
tmpDir := t.TempDir()
246266
srvFS := osfs.New(tmpDir, osfs.WithChrootOS())
247267

@@ -264,10 +284,9 @@ func TestCloneRepoSSH(t *testing.T) {
264284
},
265285
},
266286
})
267-
// TODO: ideally, we want to test the entire cloning flow.
268-
// For now, this indicates successful ssh key auth.
269-
require.ErrorContains(t, err, "repository not found")
270-
require.False(t, cloned)
287+
require.NoError(t, err)
288+
require.True(t, cloned)
289+
require.Equal(t, "Hello, world!", mustRead(t, clientFS, "/workspace/README.md"))
271290
})
272291

273292
t.Run("AuthFailure", func(t *testing.T) {
@@ -399,12 +418,12 @@ func TestSetupRepoAuth(t *testing.T) {
399418
// Anything that is not https:// or http:// is treated as SSH.
400419
kPath := writeTestPrivateKey(t)
401420
opts := &options.Options{
402-
GitURL: "git://git@host.tld:repo/path",
421+
GitURL: "git://git@host.tld:12345/path",
403422
GitSSHPrivateKeyPath: kPath,
404423
}
405424
auth := git.SetupRepoAuth(t.Logf, opts)
406425
_, ok := auth.(*gitssh.PublicKeys)
407-
require.True(t, ok)
426+
require.True(t, ok, "expected SSH auth for git:// URL")
408427
})
409428

410429
t.Run("SSH/GitUsername", func(t *testing.T) {
@@ -422,7 +441,7 @@ func TestSetupRepoAuth(t *testing.T) {
422441
t.Run("SSH/PrivateKey", func(t *testing.T) {
423442
kPath := writeTestPrivateKey(t)
424443
opts := &options.Options{
425-
GitURL: "ssh://git@host.tld:repo/path",
444+
GitURL: "ssh://git@host.tld/repo/path",
426445
GitSSHPrivateKeyPath: kPath,
427446
}
428447
auth := git.SetupRepoAuth(t.Logf, opts)
@@ -436,7 +455,7 @@ func TestSetupRepoAuth(t *testing.T) {
436455

437456
t.Run("SSH/Base64PrivateKey", func(t *testing.T) {
438457
opts := &options.Options{
439-
GitURL: "ssh://git@host.tld:repo/path",
458+
GitURL: "ssh://git@host.tld/repo/path",
440459
GitSSHPrivateKeyBase64: base64EncodeTestPrivateKey(),
441460
}
442461
auth := git.SetupRepoAuth(t.Logf, opts)
@@ -452,7 +471,7 @@ func TestSetupRepoAuth(t *testing.T) {
452471

453472
t.Run("SSH/NoAuthMethods", func(t *testing.T) {
454473
opts := &options.Options{
455-
GitURL: "ssh://git@host.tld:repo/path",
474+
GitURL: "git@host.tld:repo/path",
456475
}
457476
auth := git.SetupRepoAuth(t.Logf, opts)
458477
require.Nil(t, auth) // TODO: actually test SSH_AUTH_SOCK
@@ -481,6 +500,28 @@ func TestSetupRepoAuth(t *testing.T) {
481500
auth := git.SetupRepoAuth(t.Logf, opts)
482501
require.Nil(t, auth)
483502
})
503+
504+
t.Run("Whitespace", func(t *testing.T) {
505+
kPath := writeTestPrivateKey(t)
506+
opts := &options.Options{
507+
GitURL: "ssh://git@host.tld/repo path",
508+
GitSSHPrivateKeyPath: kPath,
509+
}
510+
auth := git.SetupRepoAuth(t.Logf, opts)
511+
_, ok := auth.(*gitssh.PublicKeys)
512+
require.True(t, ok)
513+
})
514+
515+
t.Run("LeadingTrailingWhitespace", func(t *testing.T) {
516+
kPath := writeTestPrivateKey(t)
517+
opts := &options.Options{
518+
GitURL: " ssh://git@host.tld/repo/path ",
519+
GitSSHPrivateKeyPath: kPath,
520+
}
521+
auth := git.SetupRepoAuth(t.Logf, opts)
522+
_, ok := auth.(*gitssh.PublicKeys)
523+
require.True(t, ok)
524+
})
484525
}
485526

486527
func mustRead(t *testing.T, fs billy.Filesystem, path string) string {

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ require (
1313
cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6
1414
github.com/GoogleContainerTools/kaniko v1.9.2
1515
github.com/breml/rootcerts v0.2.10
16-
github.com/chainguard-dev/git-urls v1.0.2
1716
github.com/coder/coder/v2 v2.10.1-0.20240704130443-c2d44d16a352
1817
github.com/coder/retry v1.5.1
1918
github.com/coder/serpent v0.8.0

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,6 @@ github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6
152152
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
153153
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
154154
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
155-
github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiwNkJrVcKQ=
156-
github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o=
157155
github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU=
158156
github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU=
159157
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=

integration/integration_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,14 +461,16 @@ func TestGitSSHAuth(t *testing.T) {
461461
_ = gittest.NewRepo(t, srvFS, gittest.Commit(t, "Dockerfile", "FROM "+testImageAlpine, "Initial commit"))
462462
tr := gittest.NewServerSSH(t, srvFS, signer.PublicKey())
463463

464-
_, err = runEnvbuilder(t, runOpts{env: []string{
464+
ctr, err := runEnvbuilder(t, runOpts{env: []string{
465465
envbuilderEnv("DOCKERFILE_PATH", "Dockerfile"),
466-
envbuilderEnv("GIT_URL", tr.String()+"."),
466+
envbuilderEnv("GIT_URL", tr.String()),
467467
envbuilderEnv("GIT_SSH_PRIVATE_KEY_BASE64", base64Key),
468468
}})
469-
// TODO: Ensure it actually clones but this does mean we have
470-
// successfully authenticated.
471-
require.ErrorContains(t, err, "repository not found")
469+
require.NoError(t, err)
470+
dockerFilePath := execContainer(t, ctr, "find /workspaces -name Dockerfile")
471+
require.NotEmpty(t, dockerFilePath)
472+
dockerFile := execContainer(t, ctr, "cat "+dockerFilePath)
473+
require.Contains(t, dockerFile, testImageAlpine)
472474
})
473475

474476
t.Run("Base64/Failure", func(t *testing.T) {

0 commit comments

Comments
 (0)