fix(login): avoid panic when parsing auto-discovered SSH keys (#1100)

## Problem

`tea login add` can panic while auto-discovering SSH keys. The interactive login flow calls `regexp.FindStringSubmatch` and immediately indexes `[1]` without checking whether the regex matched. When the selected key display string does not have the expected format, the returned slice is `nil` and tea crashes with:

```
panic: runtime error: index out of range [1] with length 0
```

This is the crash reported in #527.

## Root cause

`regexp.Regexp.FindStringSubmatch` returns `nil` when the input does not match. Indexing that result with `[1]` assumes a match and causes the panic. The same unchecked pattern exists for SSH certificates and plain public keys in `modules/interact/login.go`.

## Changes

- Extract auto-discovered SSH key/certificate display parsing into `parseSSHPubkeySelection`.
- Add a `regexpSubmatch` helper that returns an error when a regex does not match, so login fails with a descriptive error instead of panicking.
- Add table-driven tests for local/agent keys, local/agent certificates, and malformed input.

Fixes #527

---------

Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/tea/pulls/1100
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Lunny Xiao
2026-08-23 19:20:19 +00:00
committed by bircni
parent bfda25be63
commit 22d43ec9b6
2 changed files with 106 additions and 19 deletions
+37 -19
View File
@@ -200,25 +200,9 @@ func CreateLogin(ctx context.Context) error {
}
printTitleAndContent("Selected ssh-key:", sshKey)
// ssh certificate
if strings.Contains(sshKey, "principals") {
sshCertPrincipal = regexp.MustCompile(`.*?principals: (.*?)[,|\s]`).FindStringSubmatch(sshKey)[1]
if strings.Contains(sshKey, "(ssh-agent)") {
sshAgent = true
sshKey = ""
} else {
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
sshKey = strings.TrimSuffix(sshKey, "-cert.pub")
}
} else {
sshKeyFingerprint = regexp.MustCompile(`(SHA256:.*?)\s`).FindStringSubmatch(sshKey)[1]
if strings.Contains(sshKey, "(ssh-agent)") {
sshAgent = true
sshKey = ""
} else {
sshKey = regexp.MustCompile(`\((.*?)\)$`).FindStringSubmatch(sshKey)[1]
sshKey = strings.TrimSuffix(sshKey, ".pub")
}
sshKey, sshCertPrincipal, sshKeyFingerprint, sshAgent, err = parseSSHPubkeySelection(sshKey)
if err != nil {
return err
}
}
}
@@ -274,6 +258,40 @@ func CreateLogin(ctx context.Context) error {
return task.CreateLogin(ctx, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)
}
func parseSSHPubkeySelection(display string) (sshKey, sshCertPrincipal, sshKeyFingerprint string, sshAgent bool, err error) {
if strings.Contains(display, "principals") {
if sshCertPrincipal, err = regexpSubmatch(regexp.MustCompile(`.*?principals: (.*?)[,|\s]`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate principal from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", sshCertPrincipal, "", true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH certificate path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, "-cert.pub"), sshCertPrincipal, "", false, nil
}
if sshKeyFingerprint, err = regexpSubmatch(regexp.MustCompile(`(SHA256:.*?)\s`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key fingerprint from %q: %w", display, err)
}
if strings.HasSuffix(display, "(ssh-agent)") {
return "", "", sshKeyFingerprint, true, nil
}
if sshKey, err = regexpSubmatch(regexp.MustCompile(`\((.*?)\)$`), display); err != nil {
return "", "", "", false, fmt.Errorf("failed to parse SSH key path from %q: %w", display, err)
}
return strings.TrimSuffix(sshKey, ".pub"), "", sshKeyFingerprint, false, nil
}
func regexpSubmatch(re *regexp.Regexp, s string) (string, error) {
match := re.FindStringSubmatch(s)
if len(match) < 2 {
return "", fmt.Errorf("no match")
}
return match[1], nil
}
var tokenScopeOpts = []string{
string(gitea.AccessTokenScopeAll),
string(gitea.AccessTokenScopeRepo),
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package interact
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSSHPubkeySelection(t *testing.T) {
tests := []struct {
name string
display string
wantSSHKey string
wantCertPrincipal string
wantKeyFingerprint string
wantSSHAgent bool
wantErr bool
}{
{
name: "local ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (/home/user/.ssh/id_ed25519.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantKeyFingerprint: "SHA256:abc",
},
{
name: "agent ed25519 key",
display: "SHA256:abc ssh-ed25519 comment (ssh-agent)",
wantKeyFingerprint: "SHA256:abc",
wantSSHAgent: true,
},
{
name: "local certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1,user2 (/home/user/.ssh/id_ed25519-cert.pub)",
wantSSHKey: "/home/user/.ssh/id_ed25519",
wantCertPrincipal: "user1",
},
{
name: "agent certificate",
display: "SHA256:abc ssh-ed25519-cert-v01@openssh.com comment - principals: user1 (ssh-agent)",
wantCertPrincipal: "user1",
wantSSHAgent: true,
},
{
name: "unexpected display",
display: "ssh-ed25519 comment",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sshKey, certPrincipal, keyFingerprint, sshAgent, err := parseSSHPubkeySelection(tt.display)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSSHKey, sshKey)
assert.Equal(t, tt.wantCertPrincipal, certPrincipal)
assert.Equal(t, tt.wantKeyFingerprint, keyFingerprint)
assert.Equal(t, tt.wantSSHAgent, sshAgent)
})
}
}