diff --git a/CHANGELOG.md b/CHANGELOG.md index a65ed05f..f22af57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Removed + +- The built-in Entire DB credential store integration (`hosts.json` active-user lookup, the file/keyring token store, and OAuth refresh-token handling). `auth.Resolve` now resolves only explicit token/bearer credentials; everything else defers to the git credential helper on a 401, exactly as for any other remote. The Entire mirroring pipeline and the `git-remote-entire` helper already supply credentials directly (installation / repo-scoped tokens at the transport layer), so nothing produced the `hosts.json`/token-store layout this code read. This drops the `github.com/zalando/go-keyring` dependency and, with the file token store gone, the package now compiles on Windows without a `flock` shim. + ### Fixed - Concurrent **create** races on the target are now classified as `ErrTargetRefMoved`, matching the existing concurrent-update handling. entire-server rejects a create command (old = zero hash) for a ref that already exists with `already exists`; git-sync only plans a create for a ref it found absent at plan time, so that rejection is an unambiguous benign race — a second sync of the same repo created the ref first — exactly like the update-side `remote ref has changed`. Previously only the update reason was in `concurrentMoveMarkers`, so a create race fell through as a generic push failure and `errors.Is(err, ErrTargetRefMoved)` returned false; embedders that key redelivery/alerting off the sentinel (e.g. mirror-pipeline's worker) misclassified it as a hard sync failure. Both the create and update CAS rejections now satisfy `errors.Is(err, ErrTargetRefMoved)`. diff --git a/cmd/git-sync/internal/sha256convert/sha256convert.go b/cmd/git-sync/internal/sha256convert/sha256convert.go index bc280104..37754bf1 100644 --- a/cmd/git-sync/internal/sha256convert/sha256convert.go +++ b/cmd/git-sync/internal/sha256convert/sha256convert.go @@ -945,15 +945,12 @@ func openSource(ctx context.Context, req Request, planCfg planner.PlanConfig) (g if ep.Scheme != "http" && ep.Scheme != "https" { return nil, nil, nil, fmt.Errorf("convert-sha256 currently supports HTTP/HTTPS sources only; got %q", ep.Scheme) } - authMethod, err := auth.Resolve(auth.Endpoint{ + authMethod := auth.Resolve(auth.Endpoint{ Username: req.SourceAuth.Username, Token: req.SourceAuth.Token, BearerToken: req.SourceAuth.BearerToken, SkipTLSVerify: req.SourceAuth.SkipTLSVerify, }, ep) - if err != nil { - return nil, nil, nil, fmt.Errorf("resolve source auth: %w", err) - } httpClient := &http.Client{Transport: gitproto.NewHTTPTransport(req.SourceAuth.SkipTLSVerify)} conn := gitproto.NewHTTPConnWithClient(ep, "source", normalizeAuth(authMethod), httpClient) conn.FollowInfoRefsRedirect = req.SourceFollowInfoRefsRedirect diff --git a/docs/architecture.md b/docs/architecture.md index d7d87427..fbbb59db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,8 @@ The current transfer modes are: - `internal/validation` - input normalization and front-loaded validation - `internal/auth` - - credential lookup, Entire token handling, token store behavior + - explicit token/bearer auth and git credential-helper integration + (lookup deferred until the server returns 401) - `internal/strategy/bootstrap` - one-shot relay bootstrap and batched bootstrap - `internal/strategy/incremental` diff --git a/go.mod b/go.mod index 474c9026..c1d867ab 100644 --- a/go.mod +++ b/go.mod @@ -9,19 +9,15 @@ require ( github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521151600-590487407c38 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - github.com/zalando/go-keyring v0.2.8 - golang.org/x/sys v0.44.0 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect - github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -32,5 +28,6 @@ require ( golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bc7b5d32..5cd8b7cd 100644 --- a/go.sum +++ b/go.sum @@ -9,8 +9,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= -github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -26,8 +24,6 @@ github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4s github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521151600-590487407c38 h1:uA2L2RZQTkmvHjzBqMNMFR+UWdjicJBc0UqhCrgodZs= github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260521151600-590487407c38/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= @@ -51,13 +47,9 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= -github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 2c9ea9b6..0de787df 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -28,22 +28,13 @@ type Endpoint struct { SkipTLSVerify bool } -// Resolve resolves the auth method for the given endpoint configuration. -// 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 !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 - } - return nil, nil //nolint:nilnil // nil signals no auth method found at this stage +// Resolve resolves the auth method for the given endpoint configuration: +// explicit token/bearer flags, or nil to proceed anonymously (the git +// credential helper is consulted later, deferred until the server returns 401, +// matching git's own behaviour). The endpoint is unused now but kept in the +// signature so callers needn't special-case it. +func Resolve(raw Endpoint, _ *url.URL) Method { + return explicitAuth(raw) } func explicitAuth(raw Endpoint) Method { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index efc58678..76bfce82 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,122 +3,15 @@ package auth import ( "context" "errors" - "fmt" "net/url" "os" - "path/filepath" - "strconv" "strings" "testing" - "time" "github.com/go-git/go-git/v6/plumbing/transport" transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http" - "github.com/zalando/go-keyring" ) -func TestDecodeTokenWithExpiration(t *testing.T) { - tests := []struct { - name string - encoded string - wantToken string - wantZero bool // if true, expect time.Time zero value - wantUnix int64 // checked only when wantZero is false - }{ - { - name: "token with pipe-separated unix timestamp", - encoded: "mytoken|12345", - wantToken: "mytoken", - wantUnix: 12345, - }, - { - name: "plain token without pipe", - encoded: "plain-token", - wantToken: "plain-token", - wantZero: true, - }, - { - name: "empty string", - encoded: "", - wantToken: "", - wantZero: true, - }, - { - name: "pipe with non-numeric suffix falls back to full string", - encoded: "tok|notanumber", - wantToken: "tok|notanumber", - wantZero: true, - }, - { - name: "multiple pipes uses last one", - encoded: "a|b|99999", - wantToken: "a|b", - wantUnix: 99999, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - token, ts := decodeTokenWithExpiration(tt.encoded) - if token != tt.wantToken { - t.Errorf("token = %q, want %q", token, tt.wantToken) - } - if tt.wantZero { - if !ts.IsZero() { - t.Errorf("expected zero time, got %v", ts) - } - } else { - if ts.Unix() != tt.wantUnix { - t.Errorf("timestamp = %d, want %d", ts.Unix(), tt.wantUnix) - } - } - }) - } -} - -func TestTokenExpiredOrExpiring(t *testing.T) { - tests := []struct { - name string - expiresAt time.Time - want bool - }{ - { - name: "zero time is treated as expired", - expiresAt: time.Time{}, - want: true, - }, - { - name: "far future is not expired", - expiresAt: time.Now().Add(1 * time.Hour), - want: false, - }, - { - name: "past time is expired", - expiresAt: time.Now().Add(-1 * time.Hour), - want: true, - }, - { - name: "expiring within 5 minute window is treated as expired", - expiresAt: time.Now().Add(2 * time.Minute), - want: true, - }, - { - name: "just beyond 5 minute window is not expired", - expiresAt: time.Now().Add(10 * time.Minute), - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tokenExpiredOrExpiring(tt.expiresAt) - if got != tt.want { - t.Errorf("tokenExpiredOrExpiring(%v) = %v, want %v", tt.expiresAt, got, tt.want) - } - }) - } -} - func TestCredentialInput_FillQueryWithEmbeddedUser(t *testing.T) { ep := &url.URL{ Scheme: "https", @@ -281,7 +174,6 @@ func TestResolve(t *testing.T) { wantType string // "token", "basic", "nil" wantUser string wantPass string - wantErr bool }{ { name: "bearer token set returns TokenAuth", @@ -332,20 +224,7 @@ func TestResolve(t *testing.T) { return nil, nil } - // Also ensure ENTIRE_CONFIG_DIR points nowhere so EntireDB lookup - // doesn't find anything. - t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir()) - - got, err := Resolve(tt.raw, tt.ep) - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + got := Resolve(tt.raw, tt.ep) switch tt.wantType { case "nil": @@ -669,285 +548,3 @@ func TestGitCredentialCmdInheritsEnvWithoutOverridingTerminalPrompt(t *testing.T } } } - -func TestEndpointBaseURL(t *testing.T) { - tests := []struct { - name string - ep *url.URL - want string - }{ - { - name: "https host", - ep: &url.URL{Scheme: "https", Host: "example.com"}, - want: "https://example.com", - }, - { - name: "http host with port", - ep: &url.URL{Scheme: "http", Host: "example.com:8080"}, - want: "http://example.com:8080", - }, - { - name: "nil endpoint", - ep: nil, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := endpointBaseURL(tt.ep) - if got != tt.want { - t.Errorf("endpointBaseURL() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestEndpointCredentialHost(t *testing.T) { - tests := []struct { - name string - ep *url.URL - want string - }{ - { - name: "host without port", - ep: &url.URL{Host: "example.com"}, - want: "example.com", - }, - { - name: "host with port", - ep: &url.URL{Host: "example.com:8080"}, - want: "example.com:8080", - }, - { - name: "nil endpoint", - ep: nil, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := endpointCredentialHost(tt.ep) - if got != tt.want { - t.Errorf("endpointCredentialHost() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestReadWriteFileToken(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "tokens.json") - - // Write a token and read it back. - if err := writeFileToken(path, "svc1", "user1", "pass1"); err != nil { - t.Fatalf("writeFileToken: %v", err) - } - got, err := readFileToken(path, "svc1", "user1") - if err != nil { - t.Fatalf("readFileToken: %v", err) - } - if got != "pass1" { - t.Errorf("readFileToken = %q, want %q", got, "pass1") - } - - // Read missing service returns ErrNotFound. - _, err = readFileToken(path, "missing-svc", "user1") - if !errors.Is(err, keyring.ErrNotFound) { - t.Errorf("expected ErrNotFound for missing service, got %v", err) - } - - // Write a second service and read each back. - if err := writeFileToken(path, "svc2", "user2", "pass2"); err != nil { - t.Fatalf("writeFileToken svc2: %v", err) - } - got1, err := readFileToken(path, "svc1", "user1") - if err != nil { - t.Fatalf("readFileToken svc1 after second write: %v", err) - } - if got1 != "pass1" { - t.Errorf("svc1 token = %q, want %q", got1, "pass1") - } - got2, err := readFileToken(path, "svc2", "user2") - if err != nil { - t.Fatalf("readFileToken svc2: %v", err) - } - if got2 != "pass2" { - t.Errorf("svc2 token = %q, want %q", got2, "pass2") - } - - // Read file that doesn't exist returns ErrNotFound. - _, err = readFileToken(filepath.Join(dir, "nonexistent.json"), "svc1", "user1") - if !errors.Is(err, keyring.ErrNotFound) { - t.Errorf("expected ErrNotFound for missing file, got %v", err) - } -} - -func TestIsNotFound(t *testing.T) { - if !isNotFound(keyring.ErrNotFound) { - t.Error("expected isNotFound(keyring.ErrNotFound) = true") - } - if isNotFound(errors.New("some other error")) { - t.Error("expected isNotFound(other error) = false") - } - // Wrapped ErrNotFound should also be detected. - wrapped := fmt.Errorf("wrapped: %w", keyring.ErrNotFound) - if !isNotFound(wrapped) { - t.Error("expected isNotFound(wrapped ErrNotFound) = true") - } -} - -func TestReadFileTokenEmptyPath(t *testing.T) { - _, err := readFileToken("", "svc", "user") - if !errors.Is(err, keyring.ErrNotFound) { - t.Errorf("expected ErrNotFound for empty path, got %v", err) - } -} - -func TestWriteFileTokenEmptyPath(t *testing.T) { - err := writeFileToken("", "svc", "user", "pass") - if err == nil { - t.Error("expected error for empty path, got nil") - } - if !errors.Is(err, os.ErrInvalid) { - t.Errorf("expected os.ErrInvalid, got %v", err) - } -} - -func TestGetTokenWithRefresh(t *testing.T) { - t.Run("non-expired token returned without refresh", func(t *testing.T) { - dir := t.TempDir() - tokenPath := filepath.Join(dir, "tokens.json") - t.Setenv("ENTIRE_TOKEN_STORE", "file") - t.Setenv("ENTIRE_TOKEN_STORE_PATH", tokenPath) - - // Set up a hosts.json so lookupEntireDBToken would find a user. - configDir := t.TempDir() - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - hostsJSON := `{"example.com":{"activeUser":"alice","users":["alice"]}}` - if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), []byte(hostsJSON), 0o644); err != nil { - t.Fatal(err) - } - - // Write a non-expired token (expires 1 hour from now). - futureExpiry := time.Now().Add(1 * time.Hour).Unix() - encoded := fmt.Sprintf("my-access-token|%d", futureExpiry) - if err := WriteStoredToken(credentialService("example.com"), "alice", encoded); err != nil { - t.Fatalf("WriteStoredToken: %v", err) - } - - // getTokenWithRefresh should return the token without attempting refresh. - got, err := getTokenWithRefresh(context.Background(), "example.com", "alice", "https://example.com", false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "my-access-token" { - t.Errorf("got %q, want %q", got, "my-access-token") - } - }) - - t.Run("expired token with no refresh token returns error", func(t *testing.T) { - dir := t.TempDir() - tokenPath := filepath.Join(dir, "tokens.json") - t.Setenv("ENTIRE_TOKEN_STORE", "file") - t.Setenv("ENTIRE_TOKEN_STORE_PATH", tokenPath) - - configDir := t.TempDir() - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - - // Write an expired token (expired 1 hour ago). - pastExpiry := time.Now().Add(-1 * time.Hour).Unix() - encoded := fmt.Sprintf("stale-token|%d", pastExpiry) - if err := WriteStoredToken(credentialService("example.com"), "bob", encoded); err != nil { - t.Fatalf("WriteStoredToken: %v", err) - } - // No refresh token stored — refresh should fail. - - _, err := getTokenWithRefresh(context.Background(), "example.com", "bob", "https://example.com", false) - if err == nil { - t.Fatal("expected error for expired token with no refresh token, got nil") - } - // Issue #7: error should mention refresh failure. - if !strings.Contains(err.Error(), "refresh failed") { - t.Errorf("error should mention refresh failure, got: %v", err) - } - }) -} - -func TestReadWriteStoredTokenFileStore(t *testing.T) { - dir := t.TempDir() - tokenPath := filepath.Join(dir, "tokens.json") - t.Setenv("ENTIRE_TOKEN_STORE", "file") - t.Setenv("ENTIRE_TOKEN_STORE_PATH", tokenPath) - - // Write and read back — round-trip. - if err := WriteStoredToken("svc:test", "user1", "secret-value"); err != nil { - t.Fatalf("WriteStoredToken: %v", err) - } - got, err := ReadStoredToken("svc:test", "user1") - if err != nil { - t.Fatalf("ReadStoredToken: %v", err) - } - if got != "secret-value" { - t.Errorf("ReadStoredToken = %q, want %q", got, "secret-value") - } - - // Read a missing key returns ErrNotFound. - _, err = ReadStoredToken("svc:missing", "nobody") - if !isNotFound(err) { - t.Errorf("expected ErrNotFound for missing key, got %v", err) - } - - // Read from a non-existent file path returns ErrNotFound. - t.Setenv("ENTIRE_TOKEN_STORE_PATH", filepath.Join(dir, "nonexistent", "tokens.json")) - _, err = ReadStoredToken("svc:test", "user1") - if !isNotFound(err) { - t.Errorf("expected ErrNotFound for missing file, got %v", err) - } -} - -func TestEncodeTokenWithExpiration(t *testing.T) { - before := time.Now().Unix() - encoded := encodeTokenWithExpiration("mytoken", 3600) - after := time.Now().Unix() - - // Format should be "token|unixtime". - parts := strings.SplitN(encoded, "|", 2) - if len(parts) != 2 { - t.Fatalf("expected format 'token|unixtime', got %q", encoded) - } - if parts[0] != "mytoken" { - t.Errorf("token part = %q, want %q", parts[0], "mytoken") - } - ts, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - t.Fatalf("failed to parse timestamp: %v", err) - } - // The timestamp should be now + 3600 (within the before/after window). - if ts < before+3600 || ts > after+3600 { - t.Errorf("timestamp %d not in expected range [%d, %d]", ts, before+3600, after+3600) - } -} - -func TestCredentialService(t *testing.T) { - got := credentialService("example.com") - want := "entire:example.com" - if got != want { - t.Errorf("credentialService(%q) = %q, want %q", "example.com", got, want) - } -} - -func TestLookupEntireDBTokenNotConfigured(t *testing.T) { - // Set ENTIRE_CONFIG_DIR to an empty temp dir (no hosts.json). - configDir := t.TempDir() - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - - got, err := lookupEntireDBToken("example.com", "https://example.com", false) - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - if got != "" { - t.Errorf("expected empty string, got %q", got) - } -} diff --git a/internal/auth/entiredb.go b/internal/auth/entiredb.go deleted file mode 100644 index 72f73f25..00000000 --- a/internal/auth/entiredb.go +++ /dev/null @@ -1,223 +0,0 @@ -package auth - -import ( - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/zalando/go-keyring" -) - -func isNotFound(err error) bool { - return errors.Is(err, keyring.ErrNotFound) -} - -const entireCLIClientID = "entire-cli" - -type entireAuthHostInfo struct { - ActiveUser string `json:"activeUser"` - Users []string `json:"users"` -} - -type oauthTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` -} - -// LookupEntireDBCredential looks up credentials from the Entire token store. -// Returns (username, password, true, nil) on success, ("", "", false, nil) when -// no credential is configured, or ("", "", false, err) when a credential exists -// but refresh failed (issue #7). -func LookupEntireDBCredential(raw Endpoint, ep *url.URL) (string, string, bool, error) { - if ep == nil || ep.Host == "" { - return "", "", false, nil - } - credHost := endpointCredentialHost(ep) - token, err := lookupEntireDBToken(credHost, endpointBaseURL(ep), raw.SkipTLSVerify) - if err != nil { - return "", "", false, err - } - if token == "" { - return "", "", false, nil - } - username := raw.Username - if username == "" { - username = defaultGitUsername - } - return username, token, true, nil -} - -func endpointBaseURL(ep *url.URL) string { - if ep == nil || ep.Hostname() == "" { - return "" - } - scheme := ep.Scheme - if scheme == "" { - scheme = "https" - } - host := ep.Host // includes port if present in url.URL - return scheme + "://" + host -} - -func endpointCredentialHost(ep *url.URL) string { - if ep == nil { - return "" - } - return ep.Host // includes port if present in url.URL -} - -func lookupEntireDBToken(host, baseURL string, skipTLS bool) (string, error) { - configDir := os.Getenv("ENTIRE_CONFIG_DIR") - if configDir == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", nil //nolint:nilerr // missing config dir means no stored credentials, not an error - } - configDir = filepath.Join(home, ".config", "entire") - } - - username, ok := loadEntireDBActiveUser(host, configDir) - if !ok || username == "" { - return "", nil - } - return getTokenWithRefresh(context.Background(), host, username, baseURL, skipTLS) -} - -func loadEntireDBActiveUser(host, configDir string) (string, bool) { - data, err := os.ReadFile(filepath.Join(configDir, "hosts.json")) - if err != nil { - return "", false - } - var hosts map[string]*entireAuthHostInfo - if err := json.Unmarshal(data, &hosts); err != nil { - return "", false - } - info := hosts[host] - if info == nil || info.ActiveUser == "" { - return "", false - } - return info.ActiveUser, true -} - -// getTokenWithRefresh retrieves a token, refreshing it if expired. -// On refresh failure, returns the stale token with a nil error rather than -// propagating the refresh error silently (issue #7). -func getTokenWithRefresh(ctx context.Context, host, username, baseURL string, skipTLS bool) (string, error) { - encoded, err := ReadStoredToken(credentialService(host), username) - if err != nil { - // "Not found" means no credential is configured — not an error. - // Only propagate actual storage failures. - if isNotFound(err) { - return "", nil - } - return "", err - } - token, expiresAt := decodeTokenWithExpiration(encoded) - if token == "" { - return "", nil - } - if !tokenExpiredOrExpiring(expiresAt) { - return token, nil - } - refreshed, err := refreshAccessToken(ctx, host, username, baseURL, skipTLS) - if err != nil { - // Issue #7: surface refresh failure explicitly instead of silently reusing stale token. - return "", fmt.Errorf("token expired and refresh failed for %s@%s: %w", username, host, err) - } - return refreshed, nil -} - -func decodeTokenWithExpiration(encoded string) (string, time.Time) { - idx := strings.LastIndex(encoded, "|") - if idx == -1 { - return encoded, time.Time{} - } - token := encoded[:idx] - ts, err := strconv.ParseInt(encoded[idx+1:], 10, 64) - if err != nil { - return encoded, time.Time{} - } - return token, time.Unix(ts, 0) -} - -func tokenExpiredOrExpiring(expiresAt time.Time) bool { - if expiresAt.IsZero() { - return true - } - return time.Now().Add(5 * time.Minute).After(expiresAt) -} - -func refreshAccessToken(ctx context.Context, host, username, baseURL string, skipTLS bool) (string, error) { - refreshToken, err := ReadStoredToken(credentialService(host)+":refresh", username) - if err != nil { - return "", err - } - if refreshToken == "" || baseURL == "" { - return "", errors.New("missing refresh token or base url") - } - - form := url.Values{} - form.Set("grant_type", "refresh_token") - form.Set("refresh_token", refreshToken) - form.Set("client_id", entireCLIClientID) - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURL, "/")+"/oauth/token", strings.NewReader(form.Encode())) - if err != nil { - return "", fmt.Errorf("create token refresh request: %w", err) - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - client := &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS}, //nolint:gosec // InsecureSkipVerify is controlled by user flag - }, - } - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("execute token refresh request: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("refresh failed with status %d", resp.StatusCode) - } - - var tokenResp oauthTokenResponse - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("decode token refresh response: %w", err) - } - if tokenResp.AccessToken == "" { - return "", errors.New("empty access token in refresh response") - } - - if err := WriteStoredToken( - credentialService(host), - username, - encodeTokenWithExpiration(tokenResp.AccessToken, tokenResp.ExpiresIn), - ); err != nil { - return "", err - } - if tokenResp.RefreshToken != "" { - //nolint:errcheck // best-effort refresh token storage; access token already saved successfully - WriteStoredToken(credentialService(host)+":refresh", username, tokenResp.RefreshToken) - } - return tokenResp.AccessToken, nil -} - -func encodeTokenWithExpiration(token string, expiresIn int64) string { - return fmt.Sprintf("%s|%d", token, time.Now().Unix()+expiresIn) -} - -func credentialService(host string) string { - return "entire:" + host -} diff --git a/internal/auth/tokenstore.go b/internal/auth/tokenstore.go deleted file mode 100644 index 296864fc..00000000 --- a/internal/auth/tokenstore.go +++ /dev/null @@ -1,131 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/zalando/go-keyring" -) - -// ReadStoredToken reads a token from the configured store (keyring or file). -func ReadStoredToken(service, username string) (string, error) { - if os.Getenv("ENTIRE_TOKEN_STORE") == "file" { - return readFileToken(fileTokenPath(), service, username) - } - token, err := keyring.Get(service, username) - if err != nil { - return "", fmt.Errorf("read keyring token: %w", err) - } - return token, nil -} - -// WriteStoredToken writes a token to the configured store. -func WriteStoredToken(service, username, password string) error { - if os.Getenv("ENTIRE_TOKEN_STORE") == "file" { - return writeFileToken(fileTokenPath(), service, username, password) - } - if err := keyring.Set(service, username, password); err != nil { - return fmt.Errorf("write keyring token: %w", err) - } - return nil -} - -func fileTokenPath() string { - path := os.Getenv("ENTIRE_TOKEN_STORE_PATH") - if path != "" { - return path - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".config", "entiredb", "tokens.json") -} - -func readFileToken(path, service, username string) (string, error) { - if path == "" { - return "", keyring.ErrNotFound - } - // Acquire shared lock for concurrent read safety (issue #10). - unlock, err := flockShared(path) - if err != nil { - // If we can't lock (e.g., file doesn't exist yet), fall through to direct read. - return readFileTokenDirect(path, service, username) - } - defer unlock() - return readFileTokenDirect(path, service, username) -} - -func readFileTokenDirect(path, service, username string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", keyring.ErrNotFound - } - return "", fmt.Errorf("read token store file: %w", err) - } - var store map[string]map[string]string - if err := json.Unmarshal(data, &store); err != nil { - return "", fmt.Errorf("unmarshal token store: %w", err) - } - users := store[service] - if users == nil { - return "", keyring.ErrNotFound - } - password, ok := users[username] - if !ok { - return "", keyring.ErrNotFound - } - return password, nil -} - -// writeFileToken writes a token to the file store with exclusive file locking -// to prevent corruption from concurrent processes (issue #10). -func writeFileToken(path, service, username, password string) error { - if path == "" { - return os.ErrInvalid - } - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("create config directory: %w", err) - } - - // Acquire exclusive lock for the write. - unlock, err := flockExclusive(path) - if err != nil { - return err - } - defer unlock() - - // Re-read under lock to avoid lost updates. - store := map[string]map[string]string{} - if data, err := os.ReadFile(path); err == nil { - if err := json.Unmarshal(data, &store); err != nil { - return fmt.Errorf("unmarshal token store: %w", err) - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("read token store file: %w", err) - } - if store[service] == nil { - store[service] = map[string]string{} - } - store[service][username] = password - data, err := json.Marshal(store) - if err != nil { - return fmt.Errorf("marshal token store: %w", err) - } - // Atomic write: write to temp file then rename to prevent corruption on crash. - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - return fmt.Errorf("write token store temp file: %w", err) - } - if err := os.Rename(tmp, path); err != nil { - return fmt.Errorf("rename token store temp file: %w", err) - } - return nil -} - -// flockShared / flockExclusive are defined per-platform: tokenstore_lock_unix.go -// uses syscall.Flock, tokenstore_lock_windows.go provides a compiling fallback -// (syscall.Flock and the LOCK_* constants do not exist on Windows). diff --git a/internal/auth/tokenstore_lock_unix.go b/internal/auth/tokenstore_lock_unix.go deleted file mode 100644 index 47c29ea2..00000000 --- a/internal/auth/tokenstore_lock_unix.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build !windows - -package auth - -import ( - "fmt" - "os" - "syscall" -) - -// flockShared acquires a shared (read) lock on path+".lock". -func flockShared(path string) (func(), error) { - return flockOpen(path+".lock", syscall.LOCK_SH) -} - -// flockExclusive acquires an exclusive (write) lock on path+".lock". -func flockExclusive(path string) (func(), error) { - return flockOpen(path+".lock", syscall.LOCK_EX) -} - -func flockOpen(lockPath string, how int) (func(), error) { - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open lock file: %w", err) - } - if err := syscall.Flock(int(f.Fd()), how); err != nil { - f.Close() - return nil, fmt.Errorf("acquire file lock: %w", err) - } - return func() { - //nolint:errcheck // unlock errors on close are not actionable - syscall.Flock(int(f.Fd()), syscall.LOCK_UN) - f.Close() - }, nil -} diff --git a/internal/auth/tokenstore_lock_windows.go b/internal/auth/tokenstore_lock_windows.go deleted file mode 100644 index b07b7488..00000000 --- a/internal/auth/tokenstore_lock_windows.go +++ /dev/null @@ -1,53 +0,0 @@ -//go:build windows - -package auth - -import ( - "fmt" - "os" - - "golang.org/x/sys/windows" -) - -// Windows has no flock(2); use LockFileEx on a dedicated ".lock" file for the -// same advisory, interprocess mutual exclusion the Unix path gets from flock. -// writeFileToken relies on this to serialize its read-modify-write (and the -// shared temp-file write that precedes the rename), so a no-op would let -// concurrent logins/refreshes lose an update. - -// flockShared acquires a shared (read) lock on path+".lock". -func flockShared(path string) (func(), error) { - return flockOpen(path+".lock", 0) -} - -// flockExclusive acquires an exclusive (write) lock on path+".lock". -func flockExclusive(path string) (func(), error) { - return flockOpen(path+".lock", windows.LOCKFILE_EXCLUSIVE_LOCK) -} - -func flockOpen(lockPath string, flags uint32) (func(), error) { - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, fmt.Errorf("open lock file: %w", err) - } - // Lock the entire file range, blocking until the lock is available - // (no LOCKFILE_FAIL_IMMEDIATELY), matching flock's blocking semantics. - // - // os.OpenFile yields a synchronous handle (Go does not pass - // FILE_FLAG_OVERLAPPED), so LockFileEx blocks until the lock is granted and - // never returns ERROR_IO_PENDING — that pending/GetOverlappedResult path - // only applies to handles opened for asynchronous I/O. Treating any error - // as failure is therefore correct here; this matches the long-standing - // github.com/gofrs/flock implementation. - if err := windows.LockFileEx(windows.Handle(f.Fd()), flags, 0, maxUint32, maxUint32, new(windows.Overlapped)); err != nil { - f.Close() - return nil, fmt.Errorf("acquire file lock: %w", err) - } - return func() { - //nolint:errcheck // unlock errors on close are not actionable - windows.UnlockFileEx(windows.Handle(f.Fd()), 0, maxUint32, maxUint32, new(windows.Overlapped)) - f.Close() - }, nil -} - -const maxUint32 = ^uint32(0) diff --git a/internal/syncer/auth_test.go b/internal/syncer/auth_test.go index d22a51ac..b2352635 100644 --- a/internal/syncer/auth_test.go +++ b/internal/syncer/auth_test.go @@ -2,14 +2,8 @@ package syncer import ( "context" - "encoding/json" - "fmt" "net/http" - "net/http/httptest" - "os" - "path/filepath" "testing" - "time" "github.com/go-git/go-git/v6/plumbing/transport" transporthttp "github.com/go-git/go-git/v6/plumbing/transport/http" @@ -31,13 +25,10 @@ func TestResolveAuthMethodPrefersExplicitToken(t *testing.T) { return nil, nil } - resolved, err := auth.Resolve(auth.Endpoint{ + resolved := auth.Resolve(auth.Endpoint{ Username: "git", Token: "explicit-token", }, ep) - if err != nil { - t.Fatalf("resolve auth: %v", err) - } basic, ok := resolved.(*transporthttp.BasicAuth) if !ok { @@ -98,109 +89,3 @@ func TestNewHTTPConnUsesProvidedHTTPClient(t *testing.T) { t.Fatalf("wrapped base transport = %T, want %T", rt.base, baseTransport) } } - -func TestResolveAuthMethodUsesEntireDBStoredToken(t *testing.T) { - configDir := t.TempDir() - tokenStorePath := filepath.Join(t.TempDir(), "tokens.json") - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - t.Setenv("ENTIRE_TOKEN_STORE", "file") - t.Setenv("ENTIRE_TOKEN_STORE_PATH", tokenStorePath) - - ep, err := transport.ParseURL("https://localhost:8080/git/test/repo") - if err != nil { - t.Fatalf("new endpoint: %v", err) - } - credHost := ep.Host - writeEntireDBHostsFile(t, configDir, credHost, "test-user") - // Store token with a future expiration so it's not treated as expired (issue #7). - futureExpiry := time.Now().Unix() + 3600 - if err := auth.WriteStoredToken("entire:"+credHost, "test-user", fmt.Sprintf("stored-token|%d", futureExpiry)); err != nil { - t.Fatalf("write token: %v", err) - } - - originalCred := auth.GitCredentialCommand - t.Cleanup(func() { auth.GitCredentialCommand = originalCred }) - auth.GitCredentialCommand = func(_ context.Context, op auth.CredentialOp, input string) ([]byte, error) { - t.Fatalf("unexpected git credential %s call with input %q", op, input) - return nil, nil - } - - resolved, err := auth.Resolve(auth.Endpoint{}, ep) - if err != nil { - t.Fatalf("resolve auth: %v", err) - } - - basic, ok := resolved.(*transporthttp.BasicAuth) - if !ok { - t.Fatalf("expected basic auth, got %T", resolved) - } - if basic.Username != "git" || basic.Password != "stored-token" { - t.Fatalf("unexpected auth: %+v", basic) - } -} - -func TestResolveAuthMethodRefreshesExpiredEntireDBToken(t *testing.T) { - configDir := t.TempDir() - tokenStorePath := filepath.Join(t.TempDir(), "tokens.json") - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - t.Setenv("ENTIRE_TOKEN_STORE", "file") - t.Setenv("ENTIRE_TOKEN_STORE_PATH", tokenStorePath) - - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/oauth/token" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if err := r.ParseForm(); err != nil { - t.Fatalf("parse form: %v", err) - } - if got := r.Form.Get("refresh_token"); got != "refresh-token" { - t.Fatalf("unexpected refresh token: %s", got) - } - w.Header().Set("Content-Type", "application/json") - if _, err := w.Write([]byte(`{"access_token":"new-token","refresh_token":"new-refresh","expires_in":3600}`)); err != nil { - t.Errorf("write response: %v", err) - } - })) - defer server.Close() - - ep, err := transport.ParseURL(server.URL + "/git/test/repo") - if err != nil { - t.Fatalf("new endpoint: %v", err) - } - credHost := ep.Host - writeEntireDBHostsFile(t, configDir, credHost, "test-user") - // Expired token: expiration in the past - if err := auth.WriteStoredToken("entire:"+credHost, "test-user", "expired-token|1"); err != nil { - t.Fatalf("write expired token: %v", err) - } - if err := auth.WriteStoredToken("entire:"+credHost+":refresh", "test-user", "refresh-token"); err != nil { - t.Fatalf("write refresh token: %v", err) - } - - resolved, err := auth.Resolve(auth.Endpoint{SkipTLSVerify: true}, ep) - if err != nil { - t.Fatalf("resolve auth: %v", err) - } - - basic, ok := resolved.(*transporthttp.BasicAuth) - if !ok { - t.Fatalf("expected basic auth, got %T", resolved) - } - if basic.Password != "new-token" { - t.Fatalf("unexpected password: %q", basic.Password) - } -} - -func writeEntireDBHostsFile(t *testing.T, configDir, host, username string) { - t.Helper() - hosts := map[string]map[string]any{ - host: {"activeUser": username, "users": []string{username}}, - } - data, err := json.Marshal(hosts) - if err != nil { - t.Fatalf("marshal hosts: %v", err) - } - if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), data, 0o600); err != nil { - t.Fatalf("write hosts: %v", err) - } -} diff --git a/internal/syncer/entire_local_smoke_test.go b/internal/syncer/entire_local_smoke_test.go deleted file mode 100644 index a8b00859..00000000 --- a/internal/syncer/entire_local_smoke_test.go +++ /dev/null @@ -1,337 +0,0 @@ -package syncer - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/zalando/go-keyring" -) - -const entireLocalSmokeEnv = "GITSYNC_E2E_ENTIRE" - -type entireHosts map[string]entireHostInfo - -type entireHostInfo struct { - ActiveUser string `json:"activeUser"` - Users []string `json:"users"` -} - -type entireHostsFile struct { - ActiveHost string `json:"activeHost"` - Hosts entireHosts `json:"hosts"` -} - -func TestRun_EntireLocalPublicRepoSmoke(t *testing.T) { - if os.Getenv(entireLocalSmokeEnv) == "" { - t.Skip("set GITSYNC_E2E_ENTIRE=1 to run the Entire local smoke test") - } - - baseURL := firstNonEmpty( - os.Getenv("GITSYNC_E2E_ENTIRE_BASE_URL"), - os.Getenv("ENTIRE_BASE_URL"), - ) - if baseURL == "" { - t.Skip("set GITSYNC_E2E_ENTIRE_BASE_URL or ENTIRE_BASE_URL to your local Entire base URL") - } - - sourceURL := firstNonEmpty( - os.Getenv("GITSYNC_E2E_ENTIRE_SOURCE_URL"), - "https://github.com/entireio/cli.git", - ) - branch := firstNonEmpty( - os.Getenv("GITSYNC_E2E_ENTIRE_BRANCH"), - "main", - ) - maxPackBytes, err := envInt64Default("GITSYNC_E2E_ENTIRE_MAX_PACK_BYTES", 0) - if err != nil { - t.Fatalf("parse GITSYNC_E2E_ENTIRE_MAX_PACK_BYTES: %v", err) - } - batchMaxPackBytes, err := envInt64Default("GITSYNC_E2E_ENTIRE_BATCH_MAX_PACK_BYTES", 0) - if err != nil { - t.Fatalf("parse GITSYNC_E2E_ENTIRE_BATCH_MAX_PACK_BYTES: %v", err) - } - protocolMode := firstNonEmpty( - os.Getenv("GITSYNC_E2E_ENTIRE_PROTOCOL"), - protocolModeAuto, - ) - repoName := firstNonEmpty( - os.Getenv("GITSYNC_E2E_ENTIRE_REPO"), - "git-sync-smoke", - ) - username := os.Getenv("GITSYNC_E2E_ENTIRE_USERNAME") - token := os.Getenv("GITSYNC_E2E_ENTIRE_TOKEN") - skipTLSVerify := envBoolDefault("GITSYNC_E2E_ENTIRE_SKIP_TLS_VERIFY", true) - - host, err := credentialHost(baseURL) - if err != nil { - t.Fatalf("parse entire base URL: %v", err) - } - resolvedHost, err := resolveEntireCredentialHost(host) - if err != nil { - t.Fatalf("resolve Entire credential host: %v", err) - } - host = resolvedHost - if username == "" { - username, err = activeEntireUser(host) - if err != nil { - t.Fatalf("determine active Entire user: %v", err) - } - } - if token == "" { - token, err = lookupEntireToken(host, username) - if err != nil { - t.Fatalf("lookup Entire token: %v", err) - } - } - - if err := ensureEntireRepo(t, baseURL, repoName, skipTLSVerify); err != nil { - t.Fatalf("ensure Entire repo exists: %v", err) - } - - targetURL := strings.TrimRight(baseURL, "/") + "/git/" + username + "/" + repoName - t.Logf( - "Entire local smoke config: source=%s branch=%s target=%s repo=%s protocol=%s max_pack_bytes=%d target_max_pack_bytes=%d", - sourceURL, - branch, - targetURL, - repoName, - protocolMode, - maxPackBytes, - batchMaxPackBytes, - ) - result, err := Run(context.Background(), Config{ - Source: Endpoint{URL: sourceURL}, - Target: Endpoint{ - URL: targetURL, - Username: "git", - Token: token, - SkipTLSVerify: skipTLSVerify, - }, - Branches: []string{branch}, - Verbose: true, - MaxPackBytes: maxPackBytes, - TargetMaxPackBytes: batchMaxPackBytes, - ProtocolMode: protocolMode, - }) - if err != nil { - t.Fatalf("sync public source into Entire failed: %v", err) - } - if result.Blocked != 0 { - t.Fatalf("expected no blocked refs, got %+v", result) - } - - sourceProbe, err := Probe(context.Background(), Config{ - Source: Endpoint{URL: sourceURL}, - }) - if err != nil { - t.Fatalf("probe source refs: %v", err) - } - targetProbe, err := Probe(context.Background(), Config{ - Source: Endpoint{ - URL: targetURL, - Username: "git", - Token: token, - SkipTLSVerify: skipTLSVerify, - }, - }) - if err != nil { - t.Fatalf("probe target refs: %v", err) - } - - wantRef := "refs/heads/" + branch - sourceHash, ok := refHashFromInfos(sourceProbe.Refs, wantRef) - if !ok { - t.Fatalf("source ref %s not found in probe response", wantRef) - } - targetHash, ok := refHashFromInfos(targetProbe.Refs, wantRef) - if !ok { - t.Fatalf("target ref %s not found in probe response", wantRef) - } - if sourceHash != targetHash { - t.Fatalf("target ref %s mismatch: source=%s target=%s", wantRef, sourceHash, targetHash) - } -} - -func ensureEntireRepo(t *testing.T, baseURL, repoName string, skipTLSVerify bool) error { - t.Helper() - - bin, err := entireCLIBinary() - if err != nil { - return err - } - - listCmd := exec.CommandContext(t.Context(), bin, "repo", "list") - listCmd.Env = append(os.Environ(), entireCLIEnv(baseURL, skipTLSVerify)...) - output, err := listCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("list repos with entiredb: %w\n%s", err, strings.TrimSpace(string(output))) - } - for _, line := range strings.Split(string(output), "\n") { - if strings.TrimSpace(line) == repoName { - return nil - } - } - - createCmd := exec.CommandContext(t.Context(), bin, "repo", "create", repoName) - createCmd.Env = append(os.Environ(), entireCLIEnv(baseURL, skipTLSVerify)...) - output, err = createCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("create repo with entiredb: %w\n%s", err, strings.TrimSpace(string(output))) - } - return nil -} - -func entireCLIBinary() (string, error) { - if path := os.Getenv("GITSYNC_E2E_ENTIREDB_BIN"); path != "" { - return path, nil - } - if path, err := exec.LookPath("entiredb"); err == nil { - return path, nil - } - return "", errors.New("could not find entiredb; set GITSYNC_E2E_ENTIREDB_BIN or put entiredb on PATH") -} - -func entireCLIEnv(baseURL string, skipTLSVerify bool) []string { - env := []string{"ENTIRE_BASE_URL=" + baseURL} - if configDir := os.Getenv("ENTIRE_CONFIG_DIR"); configDir != "" { - env = append(env, "ENTIRE_CONFIG_DIR="+configDir) - } - if skipTLSVerify { - env = append(env, "ENTIRE_TLS_SKIP_VERIFY=true") - } - return env -} - -func credentialHost(baseURL string) (string, error) { - parsed, err := url.Parse(baseURL) - if err != nil { - return "", err - } - if parsed.Host == "" { - return "", fmt.Errorf("missing host in %q", baseURL) - } - return parsed.Host, nil -} - -func activeEntireUser(host string) (string, error) { - hosts, _, err := loadEntireHosts() - if err != nil { - return "", err - } - info, ok := hosts[host] - if !ok || info.ActiveUser == "" { - return "", fmt.Errorf("no active Entire user recorded for host %s", host) - } - return info.ActiveUser, nil -} - -func resolveEntireCredentialHost(requestedHost string) (string, error) { - hosts, activeHost, err := loadEntireHosts() - if err != nil { - return "", err - } - if requestedHost != "" { - if _, ok := hosts[requestedHost]; ok { - return requestedHost, nil - } - } - if activeHost != "" { - if _, ok := hosts[activeHost]; ok { - return activeHost, nil - } - } - return requestedHost, nil -} - -func lookupEntireToken(host, username string) (string, error) { - encoded, err := keyring.Get("entire:"+host, username) - if err != nil { - return "", err - } - if idx := strings.LastIndex(encoded, "|"); idx != -1 { - return encoded[:idx], nil - } - return encoded, nil -} - -func entireConfigDir() string { - if dir := os.Getenv("ENTIRE_CONFIG_DIR"); dir != "" { - return dir - } - home, err := os.UserHomeDir() - if err != nil { - return "." - } - return filepath.Join(home, ".config", "entire") -} - -func loadEntireHosts() (entireHosts, string, error) { - data, err := os.ReadFile(filepath.Join(entireConfigDir(), "hosts.json")) - if err != nil { - return nil, "", fmt.Errorf("read hosts.json: %w", err) - } - - var wrapped entireHostsFile - if err := json.Unmarshal(data, &wrapped); err == nil && len(wrapped.Hosts) > 0 { - return wrapped.Hosts, wrapped.ActiveHost, nil - } - - var hosts entireHosts - if err := json.Unmarshal(data, &hosts); err != nil { - return nil, "", fmt.Errorf("decode hosts.json: %w", err) - } - return hosts, "", nil -} - -func envBoolDefault(key string, defaultValue bool) bool { - value := strings.TrimSpace(strings.ToLower(os.Getenv(key))) - switch value { - case "1", "true", "yes", "on": - return true - case "0", "false", "no", "off": - return false - case "": - return defaultValue - default: - return defaultValue - } -} - -func envInt64Default(key string, defaultValue int64) (int64, error) { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return defaultValue, nil - } - parsed, err := strconv.ParseInt(value, 10, 64) - if err != nil { - return 0, err - } - return parsed, nil -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func refHashFromInfos(refs []RefInfo, name string) (string, bool) { - for _, ref := range refs { - if ref.Name == name { - return ref.Hash.String(), true - } - } - return "", false -} diff --git a/internal/syncer/entire_local_smoke_test_test.go b/internal/syncer/entire_local_smoke_test_test.go deleted file mode 100644 index 28a4528b..00000000 --- a/internal/syncer/entire_local_smoke_test_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package syncer - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadEntireHostsWrappedFormat(t *testing.T) { - configDir := t.TempDir() - t.Setenv("ENTIRE_CONFIG_DIR", configDir) - - data := []byte(`{ - "activeHost": "127.0.0.1:8080", - "hosts": { - "127.0.0.1:8080": { - "activeUser": "soph", - "users": ["soph"] - } - } -}`) - if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), data, 0o600); err != nil { - t.Fatalf("write hosts.json: %v", err) - } - - hosts, activeHost, err := loadEntireHosts() - if err != nil { - t.Fatalf("load hosts: %v", err) - } - if activeHost != "127.0.0.1:8080" { - t.Fatalf("unexpected active host: %q", activeHost) - } - if hosts["127.0.0.1:8080"].ActiveUser != "soph" { - t.Fatalf("unexpected active user: %+v", hosts["127.0.0.1:8080"]) - } -} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index b3f41f20..c5bb5bf4 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -369,10 +369,7 @@ func newConn(raw Endpoint, label string, stats *statsCollector, httpClient *http BearerToken: raw.BearerToken, SkipTLSVerify: raw.SkipTLSVerify, } - authMethod, err := auth.Resolve(authEp, ep) - if err != nil { - return nil, fmt.Errorf("resolve auth: %w", err) - } + authMethod := auth.Resolve(authEp, ep) stats.setSideDisplay(label, hostnameFromURL(raw.URL)) client := instrumentHTTPClient(httpClient, raw.SkipTLSVerify, label, stats) conn := gitproto.NewHTTPConnWithClient(ep, label, authMethod, client) diff --git a/mise.toml b/mise.toml index cacc6077..c05b31d2 100644 --- a/mise.toml +++ b/mise.toml @@ -34,18 +34,6 @@ run = "GITSYNC_E2E_LIVE_LINUX=1 go test ./internal/syncer -run TestBootstrap_Liv description = "Run optional live linux batched bootstrap smoke test" run = "GITSYNC_E2E_LIVE_LINUX=1 go test ./internal/syncer -run TestBootstrap_LiveLinuxSourceBatched -timeout 60m -v" -[tasks."test:entire-local-smoke"] -description = "Run optional smoke syncing a public repo into a running Entire local instance" -run = "GITSYNC_E2E_ENTIRE=1 go test ./internal/syncer -run TestRun_EntireLocalPublicRepoSmoke -timeout 30m -v" - -[tasks."test:entire-local-smoke:linux"] -description = "Run optional batched smoke syncing the Linux repo into a running Entire local instance" -run = "GITSYNC_E2E_ENTIRE=1 GITSYNC_E2E_ENTIRE_SOURCE_URL=https://github.com/torvalds/linux.git GITSYNC_E2E_ENTIRE_BRANCH=master GITSYNC_E2E_ENTIRE_PROTOCOL=v2 GITSYNC_E2E_ENTIRE_BATCH_MAX_PACK_BYTES=536870912 go test ./internal/syncer -run TestRun_EntireLocalPublicRepoSmoke -timeout 60m -v" - -[tasks."test:entire-local-smoke:linux:single"] -description = "Run optional single-pack smoke syncing the Linux repo into a running Entire local instance" -run = "GITSYNC_E2E_ENTIRE=1 GITSYNC_E2E_ENTIRE_SOURCE_URL=https://github.com/torvalds/linux.git GITSYNC_E2E_ENTIRE_BRANCH=master go test ./internal/syncer -run TestRun_EntireLocalPublicRepoSmoke -timeout 60m -v" - [tasks.build] description = "Build git-sync" run = "go build ./cmd/git-sync"