Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/git-sync/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -14,6 +15,7 @@ import (
"testing"
"time"

"entire.io/entire/git-sync/internal/auth"
"entire.io/entire/git-sync/internal/syncertest"
"entire.io/entire/git-sync/unstable"
billy "github.com/go-git/go-billy/v6"
Expand All @@ -29,6 +31,21 @@ import (
"github.com/go-git/go-git/v6/storage/memory"
)

// TestMain isolates the package's tests from the developer's local
// credential helper. Without this, `git credential fill` could find
// stored credentials for 127.0.0.1 (e.g. cached from an earlier test
// run) and turn EnsureAuthForService's would-be no-op into a real
// auth-probe POST, throwing off receive-pack POST counts.
//
// Tests that need to exercise helper behaviour explicitly should
// restore auth.GitCredentialCommand in their own setup.
func TestMain(m *testing.M) {
auth.GitCredentialCommand = func(_ context.Context, _ auth.CredentialOp, _ string) ([]byte, error) {
return nil, errors.New("no helper configured (test default)")
}
os.Exit(m.Run())
}

const testBranch = "master"
const modeReplicate = "replicate"

Expand Down
113 changes: 88 additions & 25 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"strings"

Expand All @@ -29,25 +30,20 @@ type Endpoint struct {
}

// Resolve resolves the auth method for the given endpoint configuration.
// Order: explicit flags → Entire DB token → git credential helper → anonymous.
// Order: explicit flags → Entire DB token → anonymous (with the git credential
// helper deferred until the server returns 401, matching git's own behaviour).
func Resolve(raw Endpoint, ep *url.URL) (Method, error) {
if auth := explicitAuth(raw); auth != nil {
return auth, nil
}
if ep == nil {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
if ep.Scheme != "http" && ep.Scheme != "https" {
if !isHTTPEndpoint(ep) {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
if username, password, ok, err := LookupEntireDBCredential(raw, ep); err != nil {
return nil, err // issue #7: surface refresh failure explicitly
} else if ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
if username, password, ok := lookupGitCredential(ep); ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}

Expand All @@ -65,39 +61,99 @@ func explicitAuth(raw Endpoint) Method {
return nil
}

// GitCredentialFillCommand is replaceable for testing.
var GitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", "credential", "fill")
// CredentialOp identifies a `git credential` subcommand.
type CredentialOp string

const (
CredentialOpFill CredentialOp = "fill"
CredentialOpApprove CredentialOp = "approve"
CredentialOpReject CredentialOp = "reject"
)

// newGitCredentialCmd builds the `git credential <op>` invocation. Extracted
// so tests can inspect the command's environment without exec'ing git.
func newGitCredentialCmd(ctx context.Context, op CredentialOp, input string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", "credential", string(op))
cmd.Stdin = strings.NewReader(input)
return cmd.Output()
// Suppress git's interactive username/password fallback. Without this,
// a host with no configured helper drops to a /dev/tty prompt and turns
// git-sync into an interactive command (issue #63).
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
return cmd
}

// GitCredentialCommand invokes `git credential <op>` with the given input
// (git-credential text format). Replaceable for testing.
var GitCredentialCommand = func(ctx context.Context, op CredentialOp, input string) ([]byte, error) {
return newGitCredentialCmd(ctx, op, input).Output()
}

func lookupGitCredential(ep *url.URL) (string, string, bool) {
input := credentialFillInput(ep)
// GitCredentialHelper bridges Git's credential helper protocol to HTTP auth.
// Best-effort: a missing or misbehaving helper denies credentials rather
// than failing the surrounding sync.
type GitCredentialHelper struct{}

// Lookup queries the git credential helper for credentials for ep. Returns
// ok=false if no credentials are available so the caller can surface a
// clean 401 rather than block.
//
//nolint:unparam // err is always nil today but kept for the CredentialHelper interface.
func (GitCredentialHelper) Lookup(ctx context.Context, ep *url.URL) (username, password string, ok bool, err error) {
if !isHTTPEndpoint(ep) {
return "", "", false, nil
}
input := credentialInput(ep, "", "")
if input == "" {
return "", "", false
return "", "", false, nil
}
output, err := GitCredentialFillCommand(context.Background(), input)
if err != nil {
return "", "", false
output, helperErr := GitCredentialCommand(ctx, CredentialOpFill, input)
if helperErr != nil {
return "", "", false, nil //nolint:nilerr // helper failure means "no credentials available"
}
values := parseCredentialOutput(output)
password := values["password"]
password = values["password"]
if password == "" {
return "", "", false
return "", "", false, nil
}
username := values["username"]
username = values["username"]
if username == "" {
if ep.User != nil && ep.User.Username() != "" {
username = ep.User.Username()
} else {
username = defaultGitUsername
}
}
return username, password, true
return username, password, true, nil
}

func credentialFillInput(ep *url.URL) string {
// Approve tells the helper the credentials worked.
func (h GitCredentialHelper) Approve(ctx context.Context, ep *url.URL, username, password string) {
h.signal(ctx, CredentialOpApprove, ep, username, password)
}

// Reject tells the helper the credentials failed.
func (h GitCredentialHelper) Reject(ctx context.Context, ep *url.URL, username, password string) {
h.signal(ctx, CredentialOpReject, ep, username, password)
}

func (GitCredentialHelper) signal(ctx context.Context, op CredentialOp, ep *url.URL, username, password string) {
input := credentialInput(ep, username, password)
if input == "" {
return
}
_, _ = GitCredentialCommand(ctx, op, input) //nolint:errcheck // advisory signal; helper failures swallowed
}

func isHTTPEndpoint(ep *url.URL) bool {
return ep != nil && (ep.Scheme == "http" || ep.Scheme == "https")
}

// credentialInput builds a git-credential format request body for the given
// endpoint. When username/password are set, they are appended (for use with
// `git credential approve`/`reject`). When both are empty, the result is a
// query body suitable for `git credential fill`. Explicit username overrides
// any user embedded in the endpoint URL.
func credentialInput(ep *url.URL, username, password string) string {
if ep == nil || ep.Hostname() == "" {
return ""
}
Expand All @@ -106,8 +162,15 @@ func credentialFillInput(ep *url.URL) string {
if path := strings.TrimPrefix(ep.Path, "/"); path != "" {
fmt.Fprintf(&b, "path=%s\n", path)
}
if ep.User != nil && ep.User.Username() != "" {
fmt.Fprintf(&b, "username=%s\n", ep.User.Username())
user := username
if user == "" && ep.User != nil {
user = ep.User.Username()
}
if user != "" {
fmt.Fprintf(&b, "username=%s\n", user)
}
if password != "" {
fmt.Fprintf(&b, "password=%s\n", password)
}
b.WriteString("\n")
return b.String()
Expand Down
Loading
Loading