diff --git a/cmd/issues/list_test.go b/cmd/issues/list_test.go new file mode 100644 index 00000000..2e265b39 --- /dev/null +++ b/cmd/issues/list_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package issues + +import ( + stdctx "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "golang.org/x/crypto/ssh" +) + +func TestRunIssuesListWithSSHPubkeyLoginDoesNotDeadlock(t *testing.T) { + t.Parallel() + + sshKeyPath, fingerprint := writeTestSSHKey(t) + + var versionRequests atomic.Int32 + var issueRequests atomic.Int32 + var signedVersionRequests atomic.Int32 + var signedIssueRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + versionRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedVersionRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.26.4"}`)) + case "/api/v1/repos/gitea/tea/issues": + issueRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedIssueRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + config.SetConfigForTesting(config.LocalConfig{ + Logins: []config.Login{{ + Name: "ssh-login", + URL: server.URL, + SSHKey: sshKeyPath, + SSHKeyFingerprint: fingerprint, + VersionCheck: true, + Default: true, + }}, + }) + + cmd := cli.Command{ + Name: CmdIssuesList.Name, + Flags: CmdIssuesList.Flags, + } + require.NoError(t, cmd.Set("login", "ssh-login")) + require.NoError(t, cmd.Set("repo", "gitea/tea")) + require.NoError(t, cmd.Set("output", "json")) + + done := make(chan error, 1) + go func() { + done <- RunIssuesList(stdctx.Background(), &cmd) + }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("RunIssuesList deadlocked while bootstrapping the server version for HTTPSign authentication") + } + + assert.EqualValues(t, 1, versionRequests.Load()) + assert.EqualValues(t, 0, signedVersionRequests.Load()) + assert.EqualValues(t, 1, issueRequests.Load()) + assert.EqualValues(t, 1, signedIssueRequests.Load()) +} + +func writeTestSSHKey(t *testing.T) (string, string) { + t.Helper() + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") + require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600)) + + signer, err := ssh.NewSignerFromKey(privateKey) + require.NoError(t, err) + + return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey()) +} diff --git a/go.mod b/go.mod index 70d96351..3b6d0790 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/lipgloss/v2 v2.0.4 code.gitea.io/gitea-vet v0.2.3 gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c - gitea.dev/sdk v1.1.0 + gitea.dev/sdk v1.2.0 github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 diff --git a/go.sum b/go.sum index 6e086b9d..68a58283 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c h1:8fTkq2UaV gitea.com/noerw/unidiff-comments v0.0.0-20220822113322-50f4daa0e35c/go.mod h1:Fc8iyPm4NINRWujeIk2bTfcbGc4ZYY29/oMAAGcr4qI= gitea.dev/sdk v1.1.0 h1:wLlz03WkLEiXa2bQpO1JQBTlYf7tQI2neYtZK1kU+TE= gitea.dev/sdk v1.1.0/go.mod h1:Zfl+EZXdsGGCLkryDfsmvYrQo6GKMl4U3BJA8Beu+cs= +gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo= +gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/modules/config/login_test.go b/modules/config/login_test.go new file mode 100644 index 00000000..9348c1ae --- /dev/null +++ b/modules/config/login_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + gitea "gitea.dev/sdk" + "golang.org/x/crypto/ssh" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoginClientWithSSHPubkeyDoesNotDeadlockOnFirstRequest(t *testing.T) { + t.Parallel() + + sshKeyPath, fingerprint := writeTestSSHKey(t) + + var versionRequests atomic.Int32 + var issueRequests atomic.Int32 + var signedVersionRequests atomic.Int32 + var signedIssueRequests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/version": + versionRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedVersionRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"1.26.4"}`)) + case "/api/v1/repos/gitea/tea/issues": + issueRequests.Add(1) + if r.Header.Get("Signature") != "" { + signedIssueRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + login := &Login{ + Name: "ssh-login", + URL: server.URL, + SSHKey: sshKeyPath, + SSHKeyFingerprint: fingerprint, + VersionCheck: true, + } + + type result struct { + issues []*gitea.Issue + err error + } + + done := make(chan result, 1) + go func() { + issues, _, err := login.Client().Issues.ListRepoIssues(context.Background(), "gitea", "tea", gitea.ListIssueOption{}) + done <- result{issues: issues, err: err} + }() + + select { + case res := <-done: + require.NoError(t, res.err) + assert.Empty(t, res.issues) + case <-time.After(2 * time.Second): + t.Fatal("ListRepoIssues deadlocked while bootstrapping the server version for SSH-signed requests") + } + + assert.EqualValues(t, 1, versionRequests.Load()) + assert.EqualValues(t, 0, signedVersionRequests.Load()) + assert.EqualValues(t, 1, issueRequests.Load()) + assert.EqualValues(t, 1, signedIssueRequests.Load()) +} + +func writeTestSSHKey(t *testing.T) (string, string) { + t.Helper() + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pkcs8, err := x509.MarshalPKCS8PrivateKey(privateKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}) + sshKeyPath := filepath.Join(t.TempDir(), "id_ed25519") + require.NoError(t, os.WriteFile(sshKeyPath, pemBytes, 0o600)) + + signer, err := ssh.NewSignerFromKey(privateKey) + require.NoError(t, err) + + return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey()) +}