mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
refactor: add settings, transport and alias packages (#1122)
Part 1/5 of the tea 1.0 rewrite. **Stack** (split from #1121; each PR targets `main` and builds, tests and lints on its own — the shipped CLI only changes in part 5): 1. #1122 Toolchain and foundation packages ← this PR 2. #1123 Credentials and Git 3. #1124 Terminal 4. #1125 Services 5. #1126 CLI wiring, docs, removal of the old tree ## Changes - **build:** require Go 1.27; bump gofumpt and golangci-lint to releases that can read Go 1.27 export data (own commit). - `internal/clierr`: typed errors and executable exit codes. - `internal/version`: build version and tags. - `internal/filelock`: moved unchanged from `modules/filelock`. - `internal/aliases`: user-defined command aliases. - `internal/transport`: the HTTP client the Gitea SDK runs on (part 4): token, Sudo and SSH-signature auth, retries for safe reads, connect/TLS/response-header timeouts, typed API errors that keep Gitea's message, and a redacted request log for `--debug`; plus raw requests for `tea api`, signed downloads that follow object-storage redirects, and log streaming. - `internal/settings`: split `config.yml` / `hosts.yml` with locked transactions, plus remote URL parsing. Nothing imports these packages yet. Note: `flake.nix` now asks for `go_1_27`; the pinned nixpkgs in `flake.lock` predates Go 1.27 and still needs a `nix flake update nixpkgs`. Reviewed-on: https://gitea.com/gitea/tea/pulls/1122 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
bcd62a1fb2
commit
2b218a5bae
29 files changed
+2541
-77
No files matched your search
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RemoteIsSSH reports whether a Git remote uses SSH: an ssh:// URL or an scp-style user@host:path.
|
||||
func RemoteIsSSH(remote string) bool {
|
||||
return strings.HasPrefix(remote, "ssh://") || scpLike(remote)
|
||||
}
|
||||
|
||||
func scpLike(remote string) bool {
|
||||
return !strings.Contains(remote, "://") && strings.Contains(remote, "@")
|
||||
}
|
||||
|
||||
// ParseRemote parses a Git remote URL, reading scp-style user@host:path as ssh://user@host/path
|
||||
// without interpreting shell syntax.
|
||||
func ParseRemote(raw string) (*url.URL, error) {
|
||||
if user, path, ok := strings.Cut(raw, ":"); ok && scpLike(raw) {
|
||||
raw = "ssh://" + user + "/" + path
|
||||
}
|
||||
return url.Parse(raw)
|
||||
}
|
||||
|
||||
// RepositoryURL resolves a repository URL or Git remote to a configured instance and its owner/name.
|
||||
// Unconfigured HTTP(S) URLs take the final two path segments as the repository.
|
||||
func RepositoryURL(raw string, hosts map[string]Host) (string, string, error) {
|
||||
u, err := ParseRemote(raw)
|
||||
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh") {
|
||||
return "", "", errors.New("invalid repository URL")
|
||||
}
|
||||
password := false
|
||||
if u.User != nil {
|
||||
_, password = u.User.Password()
|
||||
}
|
||||
if u.User != nil && (u.Scheme != "ssh" || password) {
|
||||
return "", "", errors.New("repository URLs must not embed credentials")
|
||||
}
|
||||
slug, cloneURL := strings.CutSuffix(strings.Trim(u.Path, "/"), ".git")
|
||||
bestHost, bestRepo, bestLen := "", "", -1
|
||||
matchedHost := false
|
||||
remoteSSH, remoteErr := SSHEndpoint(u.Host)
|
||||
for instance, host := range hosts {
|
||||
base, _ := url.Parse(instance)
|
||||
sshHost := host.SSHHost
|
||||
if sshHost == "" {
|
||||
sshHost = base.Hostname()
|
||||
if strings.Contains(sshHost, ":") {
|
||||
sshHost = "[" + sshHost + "]"
|
||||
}
|
||||
}
|
||||
prefix := strings.Trim(base.Path, "/")
|
||||
configuredSSH, configuredErr := SSHEndpoint(sshHost)
|
||||
match := (u.Scheme == "ssh" && remoteErr == nil && configuredErr == nil && remoteSSH == configuredSSH) || (u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host))
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
matchedHost = true
|
||||
candidate := slug
|
||||
if u.Scheme != "ssh" && prefix != "" {
|
||||
if !strings.HasPrefix(slug, prefix+"/") {
|
||||
continue
|
||||
}
|
||||
candidate = strings.TrimPrefix(slug, prefix+"/")
|
||||
}
|
||||
if !ValidSlug(candidate) {
|
||||
continue
|
||||
}
|
||||
if bestHost != "" && u.Scheme == "ssh" && bestHost != instance {
|
||||
return "", "", errors.New("SSH remote matches multiple instances; select --host")
|
||||
}
|
||||
if len(prefix) == bestLen && bestHost != instance {
|
||||
return "", "", errors.New("repository URL matches multiple instances; select --host")
|
||||
}
|
||||
if len(prefix) > bestLen {
|
||||
bestHost, bestRepo, bestLen = instance, candidate, len(prefix)
|
||||
}
|
||||
}
|
||||
if bestHost != "" {
|
||||
return bestHost, bestRepo, nil
|
||||
}
|
||||
if u.Scheme == "ssh" {
|
||||
return "", "", errors.New("SSH remote does not match a configured instance")
|
||||
}
|
||||
if matchedHost {
|
||||
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
||||
}
|
||||
parts := strings.Split(slug, "/")
|
||||
if len(parts) < 2 {
|
||||
return "", "", errors.New("repository URL requires owner/repository")
|
||||
}
|
||||
repo := strings.Join(parts[len(parts)-2:], "/")
|
||||
// A .git suffix marks a clone URL, whose repository may share its name with a web route.
|
||||
if !ValidSlug(repo) || (!cloneURL && looksLikeWebResourcePath(parts)) {
|
||||
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
||||
}
|
||||
u.Path = "/" + strings.Join(parts[:len(parts)-2], "/")
|
||||
u.RawPath, u.RawQuery, u.Fragment, u.User = "", "", "", nil
|
||||
instance, err := NormalizeHost(u.String())
|
||||
return instance, repo, err
|
||||
}
|
||||
|
||||
// looksLikeWebResourcePath detects Gitea HTML routes such as owner/repo/issues/7, which have a
|
||||
// web route keyword in a segment after the possible owner/repo. Taking the final two segments
|
||||
// from those paths would invent the wrong repository.
|
||||
func looksLikeWebResourcePath(parts []string) bool {
|
||||
if len(parts) < 3 {
|
||||
return false
|
||||
}
|
||||
for _, part := range parts[2:] {
|
||||
switch strings.ToLower(part) {
|
||||
case "issues", "pulls", "pull", "commit", "commits", "src", "tree", "blob", "raw",
|
||||
"compare", "releases", "tags", "wiki", "actions", "settings", "projects",
|
||||
"packages", "labels", "milestones", "activities", "forks", "watchers", "stars":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidSlug reports whether slug is exactly owner/name.
|
||||
func ValidSlug(slug string) bool {
|
||||
pieces := strings.Split(slug, "/")
|
||||
if len(pieces) != 2 {
|
||||
return false
|
||||
}
|
||||
for _, piece := range pieces {
|
||||
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
|
||||
if piece == "" || piece == "." || piece == ".." || strings.ContainsAny(piece, ":?#\\ \t\r\n") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRepositoryURL(t *testing.T) {
|
||||
ports := map[string]Host{"https://example.org:3000/base": {}, "https://example.org:3000": {}}
|
||||
bases := map[string]Host{"https://example.org": {}, "https://example.org/base": {}}
|
||||
sshPorts := map[string]Host{
|
||||
"https://example.org/first": {SSHHost: "git.example.org:2222"},
|
||||
"https://example.org/second": {SSHHost: "git.example.org:2223"},
|
||||
}
|
||||
for _, test := range []struct {
|
||||
raw string
|
||||
hosts map[string]Host
|
||||
wantHost, wantRepo string
|
||||
wantErr string
|
||||
}{
|
||||
{raw: "https://example.org:3000/base/alice/project.git", hosts: ports, wantHost: "https://example.org:3000/base", wantRepo: "alice/project"},
|
||||
{raw: "https://unconfigured.example.org/group/alice/project.git", wantHost: "https://unconfigured.example.org/group", wantRepo: "alice/project"},
|
||||
{raw: "ssh://git@git.example.org:2223/alice/project.git", hosts: sshPorts, wantHost: "https://example.org/second", wantRepo: "alice/project"},
|
||||
{raw: "git@git.example.org:alice/project.git", hosts: sshPorts, wantErr: "does not match"},
|
||||
{raw: "git@example.org:owner/repo.git", hosts: bases, wantErr: "multiple instances"},
|
||||
{raw: "https://example.org/alice/project/issues/7", hosts: bases, wantErr: "ambiguous"},
|
||||
{raw: "https://example.org/alice/project/pulls/3", hosts: bases, wantErr: "ambiguous"},
|
||||
{raw: "https://example.org/base/alice/project/src/branch/main", hosts: bases, wantErr: "ambiguous"},
|
||||
{raw: "https://example.org/alice/project/actions/runs/9", hosts: bases, wantErr: "ambiguous"},
|
||||
{raw: "https://unconfigured.example.org/alice/project/issues/7", wantErr: "ambiguous"},
|
||||
{raw: "https://unconfigured.example.org/alice/project/issues", wantErr: "ambiguous"},
|
||||
{raw: "https://unconfigured.example.org/alice/project/pulls", wantErr: "ambiguous"},
|
||||
{raw: "https://unconfigured.example.org/alice/project/src/branch/main", wantErr: "ambiguous"},
|
||||
{raw: "https://unconfigured.example.org/group/alice/issues.git", wantHost: "https://unconfigured.example.org/group", wantRepo: "alice/issues"},
|
||||
{raw: "ssh://git:private-test-password@example.org/alice/project.git", wantErr: "must not embed credentials"},
|
||||
} {
|
||||
host, repo, err := RepositoryURL(test.raw, test.hosts)
|
||||
if test.wantErr != "" {
|
||||
require.ErrorContains(t, err, test.wantErr, test.raw)
|
||||
require.NotContains(t, err.Error(), "private-test-password")
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, test.raw)
|
||||
require.Equal(t, test.wantHost, host, test.raw)
|
||||
require.Equal(t, test.wantRepo, repo, test.raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteIsSSH(t *testing.T) {
|
||||
for remote, want := range map[string]bool{
|
||||
"ssh://git@example.org/alice/project.git": true,
|
||||
"git@example.org:alice/project.git": true,
|
||||
"https://example.org/alice/project.git": false,
|
||||
"http://example.org/alice/project.git": false,
|
||||
"file:///tmp/repo.git": false,
|
||||
} {
|
||||
require.Equal(t, want, RemoteIsSSH(remote), remote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRemoteReadsSCPStyleRemotes(t *testing.T) {
|
||||
u, err := ParseRemote("git@git.example.org:alice/project.git")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ssh://git@git.example.org/alice/project.git", u.String())
|
||||
}
|
||||
|
||||
func TestValidSlugRejectsSCPStyleRemotes(t *testing.T) {
|
||||
require.True(t, ValidSlug("alice/project"))
|
||||
require.False(t, ValidSlug("git@example.org:alice/project.git"))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSHEndpoint normalizes a host or host:port, including bracketed IPv6 addresses.
|
||||
func SSHEndpoint(authority string) (string, error) {
|
||||
parsed, err := url.Parse("ssh://" + authority)
|
||||
if err != nil || parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.ContainsAny(authority, "\\ \t\r\n") {
|
||||
return "", errors.New("ssh_host must be a hostname or host:port without user, path, or credentials")
|
||||
}
|
||||
if strings.HasSuffix(authority, ":") || (strings.Contains(parsed.Hostname(), ":") && net.ParseIP(parsed.Hostname()) == nil) {
|
||||
return "", errors.New("ssh_host has an invalid host or port")
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil || number < 1 || number > 65535 {
|
||||
return "", errors.New("SSH port must be between 1 and 65535")
|
||||
}
|
||||
return net.JoinHostPort(strings.ToLower(parsed.Hostname()), strconv.Itoa(number)), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSSHEndpointNormalization(t *testing.T) {
|
||||
for input, want := range map[string]string{"Git.Example.org": "git.example.org:22", "git.example.org:2222": "git.example.org:2222", "[::1]": "[::1]:22", "[::1]:2222": "[::1]:2222"} {
|
||||
got, err := SSHEndpoint(input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
for _, input := range []string{"", "user@host", "host/path", "host?secret=x", "host#secret", "host:0", "host:65536", "host:bad", "host\\path", "host:22:", "::1", "host:"} {
|
||||
_, err := SSHEndpoint(input)
|
||||
require.Error(t, err, input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSSHSettingsCannotBePublished(t *testing.T) {
|
||||
store := New(t.TempDir())
|
||||
err := store.Update(TargetHosts, func(s *Snapshot) error {
|
||||
s.Hosts.Hosts["https://example.org"] = Host{SSHHost: "user@host:2222"}
|
||||
return nil
|
||||
})
|
||||
require.ErrorContains(t, err, "invalid ssh_host")
|
||||
snapshot, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, snapshot.Hosts.Hosts)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package settings persists preferences and instance metadata, never credentials.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/filelock"
|
||||
"github.com/adrg/xdg"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Version identifies the split configuration schema.
|
||||
const Version = "1"
|
||||
|
||||
// LockTimeout bounds the wait for another process's settings update or token refresh.
|
||||
const LockTimeout = 30 * time.Second
|
||||
|
||||
// ErrLegacy prevents overwriting the old combined login configuration.
|
||||
var ErrLegacy = errors.New("legacy tea configuration: archive the old config.yml or select a separate TEA_CONFIG_DIR, then run tea auth login; existing files have not been changed")
|
||||
|
||||
// Authentication methods and credential stores recorded for accounts.
|
||||
const (
|
||||
AuthToken = "token"
|
||||
AuthOAuth = "oauth"
|
||||
AuthSSH = "ssh"
|
||||
AuthHTTPSign = "httpsign"
|
||||
|
||||
StoreKeyring = "keyring"
|
||||
StoreFile = "file"
|
||||
)
|
||||
|
||||
// Preferences contains global, non-secret command defaults.
|
||||
type Preferences struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
DefaultHost string `yaml:"default_host,omitempty" json:"default_host"`
|
||||
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol"`
|
||||
Editor string `yaml:"editor,omitempty" json:"editor"`
|
||||
Prompt string `yaml:"prompt,omitempty" json:"prompt"`
|
||||
Pager string `yaml:"pager,omitempty" json:"pager"`
|
||||
Browser string `yaml:"browser,omitempty" json:"browser"`
|
||||
Color string `yaml:"color,omitempty" json:"color"`
|
||||
Remote string `yaml:"remote,omitempty" json:"remote"`
|
||||
Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"`
|
||||
}
|
||||
|
||||
// Account contains account-specific authentication metadata, never token values.
|
||||
type Account struct {
|
||||
AuthMethod string `yaml:"auth_method" json:"auth_method"`
|
||||
CredentialStore string `yaml:"credential_store,omitempty" json:"credential_store,omitempty"`
|
||||
SSHKey string `yaml:"ssh_key,omitempty" json:"ssh_key,omitempty"`
|
||||
SSHAgent bool `yaml:"ssh_agent,omitempty" json:"ssh_agent,omitempty"`
|
||||
SSHCertPrincipal string `yaml:"ssh_certificate_principal,omitempty" json:"ssh_certificate_principal,omitempty"`
|
||||
SSHKeyFingerprint string `yaml:"ssh_key_fingerprint,omitempty" json:"ssh_key_fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// Tokenless reports whether the account signs requests instead of storing a token.
|
||||
func (a Account) Tokenless() bool {
|
||||
return a.AuthMethod == AuthSSH || a.AuthMethod == AuthHTTPSign
|
||||
}
|
||||
|
||||
// Host contains shared connection settings and the active account.
|
||||
type Host struct {
|
||||
User string `yaml:"user,omitempty" json:"user"`
|
||||
SSHHost string `yaml:"ssh_host,omitempty" json:"ssh_host,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty" json:"insecure"`
|
||||
GitProtocol string `yaml:"git_protocol,omitempty" json:"git_protocol,omitempty"`
|
||||
OAuthClientID string `yaml:"oauth_client_id,omitempty" json:"oauth_client_id,omitempty"`
|
||||
Users map[string]Account `yaml:"users" json:"users"`
|
||||
}
|
||||
|
||||
// Hosts stores instances keyed by their normalized installation URLs.
|
||||
type Hosts struct {
|
||||
Version string `yaml:"version" json:"version"`
|
||||
Hosts map[string]Host `yaml:"hosts" json:"hosts"`
|
||||
}
|
||||
|
||||
// Snapshot is one independently loaded configuration generation.
|
||||
type Snapshot struct {
|
||||
Preferences Preferences
|
||||
Hosts Hosts
|
||||
// Configured distinguishes explicitly stored settings from built-in defaults.
|
||||
Configured map[string]bool
|
||||
}
|
||||
|
||||
// Store coordinates configuration reads and locked atomic updates.
|
||||
type Store struct {
|
||||
Dir string
|
||||
LegacyPath string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New selects an explicit directory or the platform configuration directory.
|
||||
func New(dir string) *Store {
|
||||
legacy := ""
|
||||
if dir == "" {
|
||||
dir = filepath.Join(xdg.ConfigHome, "tea")
|
||||
legacy = filepath.Join(xdg.Home, ".tea", "tea.yml")
|
||||
}
|
||||
return &Store{Dir: dir, LegacyPath: legacy}
|
||||
}
|
||||
|
||||
// Defaults returns a fresh preferences value with built-in settings.
|
||||
func Defaults() Preferences {
|
||||
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Aliases: map[string]string{}}
|
||||
}
|
||||
|
||||
// stored omits built-in defaults so that only explicit settings are persisted.
|
||||
func (p Preferences) stored() Preferences {
|
||||
defaults := Defaults()
|
||||
if p.GitProtocol == defaults.GitProtocol {
|
||||
p.GitProtocol = ""
|
||||
}
|
||||
if p.Prompt == defaults.Prompt {
|
||||
p.Prompt = ""
|
||||
}
|
||||
if p.Color == defaults.Color {
|
||||
p.Color = ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// NormalizeHost retains ports and installation paths as part of instance identity.
|
||||
func NormalizeHost(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" || (u.Scheme != "https" && u.Scheme != "http") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
||||
return "", fmt.Errorf("invalid instance URL %q: use http(s)://host[:port][/base-path]", raw)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
if (u.Scheme == "https" && u.Port() == "443") || (u.Scheme == "http" && u.Port() == "80") {
|
||||
u.Host = u.Hostname()
|
||||
if strings.Contains(u.Host, ":") {
|
||||
u.Host = "[" + u.Host + "]"
|
||||
}
|
||||
}
|
||||
// Encoded separators cannot identify an installation unambiguously.
|
||||
if strings.Contains(strings.ToLower(u.EscapedPath()), "%2f") || strings.Contains(strings.ToLower(u.EscapedPath()), "%5c") || strings.Contains(u.Path, "\\") {
|
||||
return "", fmt.Errorf("invalid instance path in %q", raw)
|
||||
}
|
||||
u.Path = strings.TrimSuffix(path.Clean("/"+u.Path), "/")
|
||||
u.RawPath = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func decode(file string, data []byte, target any) error {
|
||||
d := yaml.NewDecoder(bytes.NewReader(data))
|
||||
d.KnownFields(true)
|
||||
if err := d.Decode(target); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", file, err)
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return fmt.Errorf("%s must contain exactly one YAML document", file)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads both configuration files without creating paths or mutating globals.
|
||||
func (s *Store) Load() (Snapshot, error) {
|
||||
snapshot := Snapshot{Preferences: Defaults(), Hosts: Hosts{Version: Version, Hosts: map[string]Host{}}, Configured: map[string]bool{}}
|
||||
configPath := filepath.Join(s.Dir, "config.yml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return snapshot, err
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) && s.LegacyPath != "" {
|
||||
if _, legacyErr := os.Stat(s.LegacyPath); legacyErr == nil {
|
||||
return snapshot, ErrLegacy
|
||||
} else if !errors.Is(legacyErr, os.ErrNotExist) {
|
||||
return snapshot, legacyErr
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
var keys map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(data, &keys); err != nil {
|
||||
return snapshot, fmt.Errorf("parse %s: %w", configPath, err)
|
||||
}
|
||||
if _, ok := keys["logins"]; ok {
|
||||
return snapshot, ErrLegacy
|
||||
}
|
||||
if _, ok := keys["preferences"]; ok {
|
||||
return snapshot, ErrLegacy
|
||||
}
|
||||
version, ok := keys["version"]
|
||||
if !ok || version.Value != Version {
|
||||
return snapshot, fmt.Errorf("unsupported configuration version in %s", configPath)
|
||||
}
|
||||
for key := range keys {
|
||||
snapshot.Configured[key] = true
|
||||
}
|
||||
if err := decode(configPath, data, &snapshot.Preferences); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
}
|
||||
hostsPath := filepath.Join(s.Dir, "hosts.yml")
|
||||
hostData, err := os.ReadFile(hostsPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return snapshot, fmt.Errorf("read %s: %w", hostsPath, err)
|
||||
}
|
||||
if err == nil {
|
||||
if err := decode(hostsPath, hostData, &snapshot.Hosts); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
var keys map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(hostData, &keys); err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
version, ok := keys["version"]
|
||||
if !ok || version.Value != Version {
|
||||
return snapshot, errors.New("unsupported hosts.yml version")
|
||||
}
|
||||
}
|
||||
if snapshot.Hosts.Hosts == nil {
|
||||
snapshot.Hosts.Hosts = map[string]Host{}
|
||||
}
|
||||
if snapshot.Preferences.Aliases == nil {
|
||||
snapshot.Preferences.Aliases = map[string]string{}
|
||||
}
|
||||
return snapshot, snapshot.Validate()
|
||||
}
|
||||
|
||||
// Validate rejects unsupported versions and inconsistent instance/account settings.
|
||||
func (s Snapshot) Validate() error {
|
||||
p := s.Preferences
|
||||
if p.Version != Version || s.Hosts.Version != Version {
|
||||
return errors.New("unsupported settings version")
|
||||
}
|
||||
if p.GitProtocol != "https" && p.GitProtocol != "ssh" {
|
||||
return errors.New("git_protocol must be https or ssh")
|
||||
}
|
||||
if p.Prompt != "enabled" && p.Prompt != "disabled" {
|
||||
return errors.New("prompt must be enabled or disabled")
|
||||
}
|
||||
if p.Color != "auto" && p.Color != "always" && p.Color != "never" {
|
||||
return errors.New("color must be auto, always, or never")
|
||||
}
|
||||
if p.DefaultHost != "" {
|
||||
normalized, err := NormalizeHost(p.DefaultHost)
|
||||
if err != nil || normalized != p.DefaultHost {
|
||||
return errors.New("default_host must be a normalized instance URL")
|
||||
}
|
||||
}
|
||||
for instance, host := range s.Hosts.Hosts {
|
||||
normalized, err := NormalizeHost(instance)
|
||||
if err != nil || normalized != instance {
|
||||
return fmt.Errorf("instance %q must be a normalized URL", instance)
|
||||
}
|
||||
if host.SSHHost != "" {
|
||||
if _, err := SSHEndpoint(host.SSHHost); err != nil {
|
||||
return fmt.Errorf("invalid ssh_host for %s: %w", instance, err)
|
||||
}
|
||||
}
|
||||
if host.GitProtocol != "" && host.GitProtocol != "https" && host.GitProtocol != "ssh" {
|
||||
return fmt.Errorf("invalid git_protocol for %s", instance)
|
||||
}
|
||||
if host.User != "" {
|
||||
if _, exists := host.Users[host.User]; !exists {
|
||||
return fmt.Errorf("active user %q is missing on %s", host.User, instance)
|
||||
}
|
||||
}
|
||||
for name, account := range host.Users {
|
||||
if strings.ContainsAny(account.SSHKey+account.SSHCertPrincipal+account.SSHKeyFingerprint, "\x00\r\n") {
|
||||
return errors.New("account SSH settings cannot contain NUL or line breaks")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return errors.New("account name cannot be empty")
|
||||
}
|
||||
if account.AuthMethod != AuthToken && account.AuthMethod != AuthOAuth && !account.Tokenless() {
|
||||
return fmt.Errorf("invalid authentication method for %s on %s", name, instance)
|
||||
}
|
||||
if account.Tokenless() {
|
||||
if account.CredentialStore != "" {
|
||||
return fmt.Errorf("SSH and HTTP signature accounts cannot use a credential store for %s on %s", name, instance)
|
||||
}
|
||||
} else if account.CredentialStore != StoreKeyring && account.CredentialStore != StoreFile {
|
||||
return fmt.Errorf("invalid credential store for %s on %s", name, instance)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Target selects which settings file an update publishes.
|
||||
type Target int
|
||||
|
||||
const (
|
||||
// TargetPreferences publishes config.yml, the global non-secret defaults.
|
||||
TargetPreferences Target = iota
|
||||
// TargetHosts publishes hosts.yml, the instance and account metadata.
|
||||
TargetHosts
|
||||
)
|
||||
|
||||
// Update serializes writers, reloads current settings, and changes exactly the target file.
|
||||
// Cross-file transactions are not exposed.
|
||||
func (s *Store) Update(target Target, update func(*Snapshot) error) error {
|
||||
return s.Transaction(target, func(snapshot *Snapshot) (func() error, error) {
|
||||
return nil, update(snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// Transaction runs a compensating action under the lock if publishing the target file fails.
|
||||
// It is used to keep credential storage and account metadata consistent.
|
||||
func (s *Store) Transaction(target Target, update func(*Snapshot) (func() error, error)) error {
|
||||
return s.Locked(func(snapshot Snapshot) error {
|
||||
unpublished := func() ([]byte, error) {
|
||||
if target == TargetHosts {
|
||||
return yaml.Marshal(snapshot.Preferences)
|
||||
}
|
||||
return yaml.Marshal(snapshot.Hosts)
|
||||
}
|
||||
before, err := unpublished()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rollback, err := update(&snapshot)
|
||||
undo := func(cause error) error {
|
||||
if cause != nil && rollback != nil {
|
||||
return errors.Join(cause, rollback())
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
if after, err := unpublished(); err != nil || !bytes.Equal(before, after) {
|
||||
return undo(errors.Join(errors.New("settings update changed a file it does not write"), err))
|
||||
}
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
filename := "config.yml"
|
||||
var value any = snapshot.Preferences.stored()
|
||||
if target == TargetHosts {
|
||||
filename = "hosts.yml"
|
||||
value = snapshot.Hosts
|
||||
}
|
||||
data, err := yaml.Marshal(value)
|
||||
if err != nil {
|
||||
return undo(err)
|
||||
}
|
||||
return undo(AtomicWrite(filepath.Join(s.Dir, filename), data))
|
||||
})
|
||||
}
|
||||
|
||||
// Locked coordinates account changes and token refresh without rewriting settings.
|
||||
func (s *Store) Locked(fn func(Snapshot) error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := s.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return filelock.New(filepath.Join(s.Dir, "settings.lock"), LockTimeout).WithLock(func() error {
|
||||
snapshot, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// AtomicWrite publishes a complete private file after flushing its contents.
|
||||
func AtomicWrite(filename string, data []byte) error {
|
||||
temp, err := os.CreateTemp(filepath.Dir(filename), ".tea-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = temp.Close(); _ = os.Remove(temp.Name()) }()
|
||||
if err := temp.Chmod(0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temp.Name(), filename)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDoesNotCreateFiles(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "missing")
|
||||
s := New(dir)
|
||||
snapshot, err := s.Load()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "enabled", snapshot.Preferences.Prompt)
|
||||
_, err = os.Stat(dir)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||
}
|
||||
|
||||
func TestLegacyAndMalformedFilesAreNeverOverwritten(t *testing.T) {
|
||||
for _, input := range []string{"logins: []\npreferences: {}\n", "version: '2'\n", "version: '1'\nunknown: true\n", "version: '1'\n---\nversion: '1'\n", "version: '1'\nprompt: nonsense\n", "bad: ["} {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
s := New(t.TempDir())
|
||||
file := filepath.Join(s.Dir, "config.yml")
|
||||
require.NoError(t, os.WriteFile(file, []byte(input), 0o600))
|
||||
_, err := s.Load()
|
||||
require.Error(t, err)
|
||||
err = s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil })
|
||||
require.Error(t, err)
|
||||
actual, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, input, string(actual))
|
||||
_, err = os.Stat(filepath.Join(s.Dir, "settings.lock"))
|
||||
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||
})
|
||||
}
|
||||
t.Run("legacy tea.yml", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
legacy := filepath.Join(home, ".tea", "tea.yml")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(legacy), 0o700))
|
||||
require.NoError(t, os.WriteFile(legacy, []byte("logins: []\n"), 0o600))
|
||||
s := &Store{Dir: filepath.Join(home, "config", "tea"), LegacyPath: legacy}
|
||||
_, err := s.Load()
|
||||
require.ErrorIs(t, err, ErrLegacy)
|
||||
require.ErrorIs(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil }), ErrLegacy)
|
||||
_, err = os.Stat(s.Dir)
|
||||
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeHost(t *testing.T) {
|
||||
for raw, want := range map[string]string{"https://EXAMPLE.org:443/": "https://example.org", "https://example.org:3000/gitea/": "https://example.org:3000/gitea", "http://example.org:80": "http://example.org", "https://[::1]:443/": "https://[::1]"} {
|
||||
actual, err := NormalizeHost(raw)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, actual)
|
||||
}
|
||||
for _, raw := range []string{"example.org", "ftp://example.org", "https://user:password@example.org", "https://example.org?q=1", "https://example.org/#fragment", "https://example.org/a%2fb"} {
|
||||
_, err := NormalizeHost(raw)
|
||||
require.Error(t, err, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUpdatesAndHostSeparation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan error, 8)
|
||||
for i := range 8 {
|
||||
wg.Go(func() {
|
||||
store := New(dir)
|
||||
err := store.Update(TargetPreferences, func(snapshot *Snapshot) error {
|
||||
snapshot.Preferences.Aliases[string(rune('a'+i))] = "issue list"
|
||||
return nil
|
||||
})
|
||||
failures <- err
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
store := New(dir)
|
||||
snapshot, err := store.Load()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshot.Preferences.Aliases, 8)
|
||||
require.NoError(t, store.Update(TargetHosts, func(s *Snapshot) error {
|
||||
s.Hosts.Hosts["https://example.org/base"] = Host{User: "alice", Users: map[string]Account{"alice": {AuthMethod: "token", CredentialStore: "file"}, "bob": {AuthMethod: "oauth", CredentialStore: "keyring"}}}
|
||||
return nil
|
||||
}))
|
||||
snapshot, err = store.Load()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshot.Hosts.Hosts, 1)
|
||||
data, err := os.ReadFile(filepath.Join(dir, "config.yml"))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "alice")
|
||||
info, err := os.Stat(filepath.Join(dir, "hosts.yml"))
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS != "windows" { // Windows reports 0666 regardless of the requested mode
|
||||
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
}
|
||||
// A change to the file an update does not write fails instead of being dropped.
|
||||
require.Error(t, store.Update(TargetHosts, func(s *Snapshot) error { s.Preferences.Editor = "vi"; return nil }))
|
||||
require.Error(t, store.Update(TargetPreferences, func(s *Snapshot) error { delete(s.Hosts.Hosts, "https://example.org/base"); return nil }))
|
||||
snapshot, err = store.Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, snapshot.Preferences.Editor)
|
||||
require.Len(t, snapshot.Hosts.Hosts, 1)
|
||||
}
|
||||
|
||||
func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
|
||||
s := New(t.TempDir())
|
||||
require.NoError(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Editor = "vi"; return nil }))
|
||||
snapshot, err := s.Load()
|
||||
require.NoError(t, err)
|
||||
require.True(t, snapshot.Configured["editor"])
|
||||
for _, key := range []string{"git_protocol", "prompt", "color", "aliases"} {
|
||||
require.False(t, snapshot.Configured[key], key)
|
||||
}
|
||||
require.Equal(t, Defaults().GitProtocol, snapshot.Preferences.GitProtocol)
|
||||
require.NoError(t, s.Update(TargetPreferences, func(snapshot *Snapshot) error { snapshot.Preferences.Color = "never"; return nil }))
|
||||
data, err := os.ReadFile(filepath.Join(s.Dir, "config.yml"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "version: \"1\"\neditor: vi\ncolor: never\n", string(data))
|
||||
}
|
||||
|
||||
func TestTokenlessAccounts(t *testing.T) {
|
||||
for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} {
|
||||
require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user