From 8789f4e135b08d203c2828d576530ae5bfc5d69c Mon Sep 17 00:00:00 2001 From: ttyS3 <41882455+ttys3@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:37:11 +0000 Subject: [PATCH] feat: add secure credential management TUI Add a Bubble Tea interface for listing, creating, editing, and deleting credentials through validated structured fields. Support opt-in native keyring storage while retaining atomic, Git-compatible credential file management. Co-Authored-By: Codex (gpt-5.6-sol) --- CHANGELOG.md | 22 + README.md | 103 +++- atomic_file.go | 247 ++++++++++ atomic_file_test.go | 122 +++++ atomic_replace_unix.go | 9 + atomic_replace_windows.go | 21 + credential_store.go | 281 +++++++++++ credential_store_test.go | 269 +++++++++++ file_store.go | 271 +++++++++++ file_store_test.go | 298 ++++++++++++ go.mod | 31 +- go.sum | 64 +++ keyring_store.go | 663 ++++++++++++++++++++++++++ keyring_store_test.go | 684 +++++++++++++++++++++++++++ main.go | 116 +++-- main_test.go | 38 +- private_file_lock_unix.go | 25 + private_file_lock_windows.go | 34 ++ tui.go | 877 +++++++++++++++++++++++++++++++++++ tui_test.go | 361 ++++++++++++++ 20 files changed, 4500 insertions(+), 36 deletions(-) create mode 100644 atomic_file.go create mode 100644 atomic_file_test.go create mode 100644 atomic_replace_unix.go create mode 100644 atomic_replace_windows.go create mode 100644 credential_store.go create mode 100644 credential_store_test.go create mode 100644 file_store.go create mode 100644 file_store_test.go create mode 100644 go.sum create mode 100644 keyring_store.go create mode 100644 keyring_store_test.go create mode 100644 private_file_lock_unix.go create mode 100644 private_file_lock_windows.go create mode 100644 tui.go create mode 100644 tui_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b0ef9..68d1491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a Bubble Tea-based credential management TUI with structured, validated + fields for listing, creating, editing, and deleting credentials. +- Add opt-in storage through macOS Keychain, Linux/BSD Secret Service, and + Windows Credential Manager, with explicit `keyring` and `auto` lookup modes. + +### Changed + +- Preserve the existing credential-file backend while making TUI writes + atomic, concurrency-aware, scope-ordered, and permission-restricted. +- Require Go 1.25 or newer for the Bubble Tea v2 interface. + +### Security + +- Keep secrets out of TUI lists, confirmation screens, and the on-disk keyring + index; verify protected payload metadata before returning a credential. +- Make keyring edits and deletions interruption-safe with atomic index switches + and verified cleanup of obsolete protected items. +- Generate credential URLs from validated structured fields instead of asking + users to manually encode usernames, tokens, hosts, and paths. + ## [1.1.3] - 2026-09-04 ### Changed diff --git a/README.md b/README.md index 15275c6..1d79128 100644 --- a/README.md +++ b/README.md @@ -29,18 +29,113 @@ without being written to the personal store at `~/.git-credentials`. go install github.com/ttys3/git-credential-readonly@latest ``` -The helper supports these actions: +The helper supports Git's credential actions and an explicit management UI: ```text -git-credential-readonly +git-credential-readonly ``` +Git can call `get` as usual. The `store` and `erase` actions remain no-ops so +Git can never mutate credentials implicitly; only an interactive user in the +management UI can add, edit, or delete an entry. + For a single default credential file, configure it as follows: ```shell git config --global credential.helper readonly ``` +## Manage credentials with the TUI + +Run the interactive manager with: + +```shell +git-credential-readonly manage +``` + +The TUI is built with the actively maintained +[Bubble Tea](https://github.com/charmbracelet/bubbletea) framework and its +official [Bubbles](https://github.com/charmbracelet/bubbles) components. Use +the arrow keys or `j`/`k` to select an entry, Enter to edit it, and +`/` to filter a long list. `tui` is an alias for `manage`. + +The manager provides two storage backends: + +| Backend | Secret storage | Notes | +| --- | --- | --- | +| System keyring (recommended) | macOS Keychain, Linux/BSD Secret Service, or Windows Credential Manager | The password or token never appears in the on-disk index. | +| Credential file | The selected `--file` in standard `git-credential-store` URL format | Preserved for complete compatibility with existing installations. | + +New credentials are entered as separate protocol, host, path, username, and +password/token fields. The manager validates every field and performs the URL +encoding, so characters such as `@`, `:`, `/`, `+`, `?`, and `#` in a token do +not corrupt the credential URL. Passwords and tokens are masked, omitted from +the list and confirmation screens, and never written to the debug log. When +editing an entry, leave the password/token field empty to retain its current +value. + +Deleting an entry requires a separate confirmation. Credential-file updates +preserve unrecognized lines, reject concurrent changes, place specific paths +before broader scopes, and replace the file atomically with mode `0600` on +POSIX systems. Writes use Git's temporary `.lock` convention and remove +the lock by atomically renaming it into place, following Git's +[official lockfile protocol](https://github.com/git/git/blob/v2.55.0/lockfile.h), +so the file remains interoperable with `git credential-store`. + +### Use the system keyring for Git lookups + +The default lookup backend remains `file`, so upgrading does not change any +existing Git configuration. After adding credentials to the system keyring, +opt in with `--backend keyring`, or use `auto` to check the keyring first and +then fall back to the configured credential file: + +```ini +[credential] + helper = + helper = readonly --backend auto + useHttpPath = true +``` + +Use a URL-specific credential section instead if only selected hosts should +send paths to helpers. See +[`credential.useHttpPath`](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath) +and the ordering guidance below. For safety, the keyring backend never returns +a path-scoped secret when Git omits the request path; enable `useHttpPath`, or +add an intentionally host-wide entry with an empty path. + +The keyring backend uses +[`zalando/go-keyring`](https://github.com/zalando/go-keyring). On Linux and BSD, +a [Secret Service](https://specifications.freedesktop.org/secret-service-spec/latest/) +provider such as GNOME Keyring and a working D-Bus session must be available. +This is normally already true in a desktop login session; headless sessions +may need explicit setup. The `auto` backend can still fall back to the file if +the keyring cannot be reached. + +Because the native keyring API cannot enumerate application secrets, the +manager keeps a versioned metadata index in the operating system's user config +directory, normally: + +```text +~/.config/git-credential-readonly/keyring-index.json +``` + +The index contains only opaque IDs plus protocol, host, path, and username; it +never contains passwords or tokens and is written with mode `0600` on POSIX. +Each protected keyring payload also contains its own scope metadata, which is +verified against the index again before a credential is returned. Use +`--keyring-index ` only when a non-default metadata location is needed. +Keyring mutations are serialized with an empty `.transaction-lock` sidecar; +that advisory lock contains no credential data. +Edits create a new opaque keyring item before atomically switching the index; +old items remain in a metadata-only pending-cleanup list until they are deleted +from the keyring. A normal edit therefore never removes the old item before the +replacement is indexed, and interrupted cleanup is retried by the next keyring +change. + +The TUI never migrates or deletes plaintext credentials automatically. Add and +verify the secure entry first, then explicitly delete the old file entry if +you want to complete a migration. + ## Multiple credentials and inherited helpers The order of `credential.helper` entries is significant. Git tries helpers in @@ -142,6 +237,9 @@ usernames and tokens, and restrict their permissions: chmod 600 ~/.git-credentials ~/.git-credentials-work ``` +The TUI writes this exact format when the Credential file backend is selected, +so files remain usable by Git's built-in `credential-store` helper. + ### Troubleshooting To see which helper Git actually executes without allowing an interactive @@ -178,6 +276,7 @@ git config --show-origin --show-scope \ - [Git credential storage](https://git-scm.com/book/en/v2/Git-Tools-Credential-Storage#_a_custom_credential_cache) - [`git credential` input/output format](https://git-scm.com/docs/git-credential#_inputoutput_format) - [`git-credential-store` storage and lookup behavior](https://git-scm.com/docs/git-credential-store#_storage_format) +- [Git's atomic lockfile protocol](https://github.com/git/git/blob/v2.55.0/lockfile.h) - [`credential.helper` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialhelper) - [`credential.useHttpPath` configuration](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-credentialuseHttpPath) - [Git credential-context matching](https://git-scm.com/docs/gitcredentials#Documentation/gitcredentials.txt-CREDENTIALCONTEXTS) diff --git a/atomic_file.go b/atomic_file.go new file mode 100644 index 0000000..5c94018 --- /dev/null +++ b/atomic_file.go @@ -0,0 +1,247 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const privateFileLockTimeout = time.Second + +type lockedPrivatePath struct { + path string + lock *os.File +} + +func writePrivateFile(path string, data []byte) error { + return writePrivateFileIfUnchanged(path, data, "") +} + +func writePrivateFileIfUnchanged(path string, data []byte, expectedRevision string) error { + resolvedPath, directory, err := preparePrivatePath(path) + if err != nil { + return err + } + + lockPath := resolvedPath + ".lock" + lockFile, err := acquireCredentialLockFile(lockPath) + if err != nil { + return err + } + committed := false + defer func() { + _ = lockFile.Close() + if !committed { + _ = os.Remove(lockPath) + } + }() + + if expectedRevision != "" { + currentRevision, err := privateFileRevision(resolvedPath) + if err != nil { + return err + } + if currentRevision != expectedRevision { + return errCredentialChanged + } + } + + if err := lockFile.Chmod(0o600); err != nil { + return fmt.Errorf("restrict credential lock file permissions: %w", err) + } + if _, err := io.Copy(lockFile, bytes.NewReader(data)); err != nil { + return fmt.Errorf("write credential lock file: %w", err) + } + if err := lockFile.Sync(); err != nil { + return fmt.Errorf("sync credential lock file: %w", err) + } + if err := lockFile.Close(); err != nil { + return fmt.Errorf("close credential lock file: %w", err) + } + if err := replaceFile(lockPath, resolvedPath); err != nil { + return fmt.Errorf("replace credential file: %w", err) + } + committed = true + + // Syncing the containing directory makes the rename durable on filesystems + // that support directory fsync. Some platforms reject it, so it is best + // effort after the data itself has been synced. + if directoryHandle, err := os.Open(directory); err == nil { + _ = directoryHandle.Sync() + _ = directoryHandle.Close() + } + return nil +} + +func lockPrivatePath(path string) (*lockedPrivatePath, error) { + resolvedPath, _, err := preparePrivatePath(path) + if err != nil { + return nil, err + } + + lock, err := acquireAdvisoryFileLock(resolvedPath + ".transaction-lock") + if err != nil { + return nil, err + } + return &lockedPrivatePath{path: resolvedPath, lock: lock}, nil +} + +func (p *lockedPrivatePath) release() { + if p == nil || p.lock == nil { + return + } + _ = unlockPrivateFile(p.lock) + _ = p.lock.Close() + p.lock = nil +} + +func preparePrivatePath(path string) (string, string, error) { + resolvedPath, err := resolveCredentialWritePath(path) + if err != nil { + return "", "", err + } + directory := filepath.Dir(resolvedPath) + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", "", fmt.Errorf("create credential directory: %w", err) + } + return resolvedPath, directory, nil +} + +func acquireCredentialLockFile(lockPath string) (*os.File, error) { + deadline := time.Now().Add(privateFileLockTimeout) + for { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + return file, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("create credential lock file: %w", err) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("%w: %s", errCredentialStoreLocked, lockPath) + } + time.Sleep(25 * time.Millisecond) + } +} + +func acquireAdvisoryFileLock(lockPath string) (*os.File, error) { + file, err := openAdvisoryFileLock(lockPath) + if err != nil { + return nil, err + } + + deadline := time.Now().Add(privateFileLockTimeout) + for { + locked, err := tryLockPrivateFile(file) + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("lock credential store: %w", err) + } + if locked { + return file, nil + } + if time.Now().After(deadline) { + _ = file.Close() + return nil, fmt.Errorf("%w: %s", errCredentialStoreLocked, lockPath) + } + time.Sleep(25 * time.Millisecond) + } +} + +func openAdvisoryFileLock(lockPath string) (*os.File, error) { + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open credential lock file: %w", err) + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("inspect open credential lock file: %w", err) + } + pathInfo, err := os.Lstat(lockPath) + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("inspect credential lock file: %w", err) + } + if pathInfo.Mode()&os.ModeSymlink != 0 || !openedInfo.Mode().IsRegular() || + !pathInfo.Mode().IsRegular() || !os.SameFile(openedInfo, pathInfo) { + _ = file.Close() + return nil, errors.New("credential lock path must be a regular file, not a symlink") + } + return file, nil +} + +func privateFileRevision(path string) (string, error) { + data, err := readFileLimited(path, maxCredentialFileBytes) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return credentialFileRevision(nil), nil + } + return "", fmt.Errorf("verify credential file revision: %w", err) + } + return credentialFileRevision(data), nil +} + +func readFileLimited(path string, maximumBytes int64) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, errors.New("credential store path must be a regular file") + } + if info.Size() > maximumBytes { + return nil, fmt.Errorf("credential store exceeds %d bytes", maximumBytes) + } + + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + openedInfo, err := file.Stat() + if err != nil { + return nil, err + } + if !openedInfo.Mode().IsRegular() { + return nil, errors.New("credential store path must be a regular file") + } + if openedInfo.Size() > maximumBytes { + return nil, fmt.Errorf("credential store exceeds %d bytes", maximumBytes) + } + + data, err := io.ReadAll(io.LimitReader(file, maximumBytes+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > maximumBytes { + return nil, fmt.Errorf("credential store exceeds %d bytes", maximumBytes) + } + return data, nil +} + +func resolveCredentialWritePath(path string) (string, error) { + if path == "" { + return "", errors.New("credential store path is empty") + } + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return path, nil + } + return "", fmt.Errorf("inspect credential file: %w", err) + } + if info.Mode()&os.ModeSymlink == 0 { + return path, nil + } + + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("resolve credential file symlink: %w", err) + } + return resolved, nil +} diff --git a/atomic_file_test.go b/atomic_file_test.go new file mode 100644 index 0000000..370cd6c --- /dev/null +++ b/atomic_file_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +func TestWritePrivateFileRejectsChangedRevision(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + if err := os.WriteFile(path, []byte("original\n"), 0o600); err != nil { + t.Fatal(err) + } + revision := credentialFileRevision([]byte("original\n")) + if err := os.WriteFile(path, []byte("changed\n"), 0o600); err != nil { + t.Fatal(err) + } + + err := writePrivateFileIfUnchanged(path, []byte("replacement\n"), revision) + if !errors.Is(err, errCredentialChanged) { + t.Fatalf("error = %v, want changed revision error", err) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(data) != "changed\n" { + t.Fatalf("file = %q, want concurrent content", data) + } + assertNoCredentialLockFile(t, path+".lock") +} + +func TestWritePrivateFileSerializesConcurrentWriters(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + initial := []byte("initial\n") + if err := os.WriteFile(path, initial, 0o600); err != nil { + t.Fatal(err) + } + revision := credentialFileRevision(initial) + + start := make(chan struct{}) + results := make(chan error, 2) + var writers sync.WaitGroup + for _, data := range [][]byte{[]byte("writer-one\n"), []byte("writer-two\n")} { + data := data + writers.Add(1) + go func() { + defer writers.Done() + <-start + results <- writePrivateFileIfUnchanged(path, data, revision) + }() + } + close(start) + writers.Wait() + close(results) + + successes := 0 + changed := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, errCredentialChanged): + changed++ + default: + t.Fatalf("unexpected write error: %v", err) + } + } + if successes != 1 || changed != 1 { + t.Fatalf("successes = %d, changed = %d; want 1 and 1", successes, changed) + } + assertNoCredentialLockFile(t, path+".lock") +} + +func TestAcquireAdvisoryFileLockRejectsSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation commonly requires elevated privileges on Windows") + } + directory := t.TempDir() + target := filepath.Join(directory, "target") + lockPath := filepath.Join(directory, "credentials.lock") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, lockPath); err != nil { + t.Fatal(err) + } + + lock, err := acquireAdvisoryFileLock(lockPath) + if lock != nil { + _ = lock.Close() + t.Fatal("symlink lock unexpectedly returned a file") + } + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("error = %v, want unsafe lock-path error", err) + } +} + +func TestReadFileLimitedEnforcesSizeAndRegularFile(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "credentials") + if err := os.WriteFile(path, []byte("123456789"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readFileLimited(path, 8); err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("oversized file error = %v", err) + } + if _, err := readFileLimited(directory, 8); err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("directory error = %v", err) + } +} + +func assertNoCredentialLockFile(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("credential lock file remains after write: %v", err) + } +} diff --git a/atomic_replace_unix.go b/atomic_replace_unix.go new file mode 100644 index 0000000..4d3ad98 --- /dev/null +++ b/atomic_replace_unix.go @@ -0,0 +1,9 @@ +//go:build !windows + +package main + +import "os" + +func replaceFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/atomic_replace_windows.go b/atomic_replace_windows.go new file mode 100644 index 0000000..923bd1d --- /dev/null +++ b/atomic_replace_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package main + +import "golang.org/x/sys/windows" + +func replaceFile(source, destination string) error { + sourceUTF16, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationUTF16, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFileEx( + sourceUTF16, + destinationUTF16, + windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH, + ) +} diff --git a/credential_store.go b/credential_store.go new file mode 100644 index 0000000..2c45bc2 --- /dev/null +++ b/credential_store.go @@ -0,0 +1,281 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "unicode" +) + +type credentialBackend string + +type credentialIdentity struct { + protocol string + host string + path string + username string +} + +const ( + fileBackend credentialBackend = "file" + keyringBackend credentialBackend = "keyring" + + maxCredentialFieldBytes = 64 * 1024 +) + +var ( + errCredentialChanged = errors.New("credential store changed; reload before editing") + errDuplicateCredential = errors.New("a credential with the same scope and username already exists") + errCredentialStoreLocked = errors.New("credential store is locked by another process") + protocolPattern = regexp.MustCompile(`^[a-z][a-z0-9+.-]*$`) +) + +// credentialRecord intentionally never carries a password. Backends retrieve +// the existing secret only while applying an edit or satisfying a Git lookup. +type credentialRecord struct { + id string + backend credentialBackend + credential credential + + // Stores use revision and position to avoid editing the wrong entry if + // another process changes the backing metadata while the TUI is open. + revision string + position int +} + +type managedCredentialStore interface { + Backend() credentialBackend + DisplayName() string + List() ([]credentialRecord, error) + Add(credential) error + Update(credentialRecord, credential) error + Delete(credentialRecord) error + Lookup(*credential) (*credential, error) +} + +func normalizeCredentialForStorage(value credential) credential { + // Git compares credential protocol and host fields exactly. Trim accidental + // surrounding whitespace, but preserve case so a TUI-written credential has + // the same matching semantics as one written by git credential-store. + value.protocol = strings.TrimSpace(value.protocol) + value.host = strings.TrimSpace(value.host) + + if value.path != "/" { + value.path = strings.TrimPrefix(value.path, "/") + value.path = strings.TrimRight(value.path, "/") + } + + return value +} + +func validateCredentialForStorage(value credential, requirePassword bool) error { + if err := validateCredentialProtocol(value.protocol); err != nil { + return err + } + if err := validateCredentialHost(value.protocol, value.host); err != nil { + return err + } + if err := validateCredentialPath(value.path); err != nil { + return err + } + if err := validateCredentialUsername(value.username); err != nil { + return err + } + if err := validateCredentialPassword(value.password, requirePassword); err != nil { + return err + } + + return nil +} + +func validateCredentialProtocol(protocol string) error { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + if !protocolPattern.MatchString(protocol) { + return errors.New("protocol must be a URI scheme such as https") + } + return nil +} + +func validateCredentialHost(protocol, host string) error { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + host = strings.TrimSpace(host) + if err := validateCredentialText("host", host, false); err != nil { + return err + } + if strings.ContainsAny(host, "/?#@") { + return errors.New("host must contain only a hostname and optional port") + } + if strings.HasSuffix(host, ":") { + return errors.New("host must not end with an empty port") + } + + parsed, err := url.Parse(protocol + "://" + host) + if err != nil { + return fmt.Errorf("invalid host or port: %w", err) + } + if parsed.Hostname() == "" || parsed.User != nil || parsed.Path != "" || + parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("host must contain only a hostname and optional port") + } + if port := parsed.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return errors.New("port must be between 1 and 65535") + } + } + return nil +} + +func validateCredentialPath(path string) error { + if path != "/" { + path = strings.TrimPrefix(path, "/") + path = strings.TrimRight(path, "/") + } + if err := validateCredentialText("path", path, true); err != nil { + return err + } + if path == "" || path == "/" { + return nil + } + for _, segment := range strings.Split(path, "/") { + if segment == "" { + return errors.New("path must not contain empty segments") + } + decoded := decodePercentEscapes(segment) + if decoded == "." || decoded == ".." { + return errors.New("path must not contain . or .. segments") + } + } + return nil +} + +func validateCredentialUsername(username string) error { + return validateCredentialText("username", username, false) +} + +func validateCredentialPassword(password string, required bool) error { + if password == "" && !required { + return nil + } + return validateCredentialText("password or token", password, false) +} + +func validateCredentialText(name, value string, allowEmpty bool) error { + if !allowEmpty && value == "" { + return fmt.Errorf("%s is required", name) + } + if len(value) > maxCredentialFieldBytes { + return fmt.Errorf("%s is too long", name) + } + for _, r := range value { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return fmt.Errorf("%s must not contain control characters", name) + } + } + return nil +} + +func formatCredentialURL(value credential) (string, error) { + value = normalizeCredentialForStorage(value) + if err := validateCredentialForStorage(value, true); err != nil { + return "", err + } + + parsed := url.URL{ + Scheme: value.protocol, + Host: value.host, + User: url.UserPassword(value.username, value.password), + } + if value.path != "" { + if value.path == "/" { + parsed.Path = "/" + } else { + parsed.Path = "/" + value.path + } + } + + encoded := parsed.String() + if len(encoded)+1 > maxCredentialFieldBytes { + return "", errors.New("encoded credential is too long") + } + return encoded, nil +} + +func displayCredential(value credential) string { + parsed := url.URL{ + Scheme: value.protocol, + Host: value.host, + User: url.User(value.username), + } + if value.path != "" { + if value.path == "/" { + parsed.Path = "/" + } else { + parsed.Path = "/" + value.path + } + } + return safeTerminalText(parsed.String()) +} + +func safeTerminalText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) { + return '\uFFFD' + } + return r + }, value) +} + +func sameCredentialIdentity(left, right credential) bool { + return credentialIdentityOf(left) == credentialIdentityOf(right) +} + +func credentialIdentityOf(value credential) credentialIdentity { + value = normalizeCredentialForStorage(value) + return credentialIdentity{ + protocol: value.protocol, + host: value.host, + path: value.path, + username: value.username, + } +} + +func sameCredentialMetadata(left, right credential) bool { + left.password = "" + right.password = "" + return sameCredentialIdentity(left, right) +} + +func findDuplicateCredential(existing []credential, candidate credential) bool { + for _, current := range existing { + if sameCredentialIdentity(current, candidate) { + return true + } + } + return false +} + +// credentialInsertionIndex places a more-specific path before the first +// broader scope that would otherwise shadow it. Unrelated entries retain their +// existing order, and broad credentials are appended after specific ones. +func credentialInsertionIndex(existing []credential, candidate credential) int { + candidate = normalizeCredentialForStorage(candidate) + if candidate.path == "" { + return len(existing) + } + + for i, current := range existing { + current = normalizeCredentialForStorage(current) + if current.protocol != candidate.protocol || current.host != candidate.host || + current.path == "" || len(candidate.path) <= len(current.path) { + continue + } + if matchCredentialPath(current.path, candidate.path) { + return i + } + } + return len(existing) +} diff --git a/credential_store_test.go b/credential_store_test.go new file mode 100644 index 0000000..8fe50a0 --- /dev/null +++ b/credential_store_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNormalizeAndValidateCredentialForStorage(t *testing.T) { + tests := []struct { + name string + value credential + wantErr string + }{ + { + name: "valid structured credential", + value: credential{ + protocol: "https", + host: "gitlab.example.com:8443", + path: "group/subgroup/repository.git", + username: "developer", + password: "token", + }, + }, + { + name: "invalid protocol", + value: credential{ + protocol: "https://", + host: "example.com", + username: "user", + password: "token", + }, + wantErr: "protocol", + }, + { + name: "scheme pasted into host", + value: credential{ + protocol: "https", + host: "https://example.com", + username: "user", + password: "token", + }, + wantErr: "host", + }, + { + name: "path pasted into host", + value: credential{ + protocol: "https", + host: "example.com/org/repo", + username: "user", + password: "token", + }, + wantErr: "host", + }, + { + name: "invalid port", + value: credential{ + protocol: "https", + host: "example.com:70000", + username: "user", + password: "token", + }, + wantErr: "port", + }, + { + name: "empty port", + value: credential{ + protocol: "https", + host: "example.com:", + username: "user", + password: "token", + }, + wantErr: "empty port", + }, + { + name: "dot path segment", + value: credential{ + protocol: "https", + host: "example.com", + path: "org/../private", + username: "user", + password: "token", + }, + wantErr: "segments", + }, + { + name: "encoded dot path segment", + value: credential{ + protocol: "https", + host: "example.com", + path: "org/%2e%2e/private", + username: "user", + password: "token", + }, + wantErr: "segments", + }, + { + name: "empty path segment", + value: credential{ + protocol: "https", + host: "example.com", + path: "org//repo", + username: "user", + password: "token", + }, + wantErr: "empty segments", + }, + { + name: "missing username", + value: credential{ + protocol: "https", + host: "example.com", + password: "token", + }, + wantErr: "username", + }, + { + name: "missing token", + value: credential{ + protocol: "https", + host: "example.com", + username: "user", + }, + wantErr: "password or token", + }, + { + name: "newline in token", + value: credential{ + protocol: "https", + host: "example.com", + username: "user", + password: "token\npassword=leak", + }, + wantErr: "control characters", + }, + { + name: "bidirectional override in username", + value: credential{ + protocol: "https", + host: "example.com", + username: "user\u202eexample", + password: "token", + }, + wantErr: "control characters", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value := normalizeCredentialForStorage(test.value) + err := validateCredentialForStorage(value, true) + if test.wantErr == "" { + if err != nil { + t.Fatalf("validate credential: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +func TestNormalizeCredentialForStorage(t *testing.T) { + got := normalizeCredentialForStorage(credential{ + protocol: " HTTPS ", + host: " GitLab.Example.COM:443 ", + path: "/group/subgroup/", + username: "user", + password: "token", + }) + if got.protocol != "HTTPS" || got.host != "GitLab.Example.COM:443" || got.path != "group/subgroup" { + t.Fatalf("unexpected normalized credential: %+v", got) + } +} + +func TestCredentialIdentityPreservesGitCaseSensitiveMatching(t *testing.T) { + lower := credential{protocol: "https", host: "example.com", username: "user"} + mixedCase := credential{protocol: "HTTPS", host: "Example.COM", username: "user"} + if sameCredentialIdentity(lower, mixedCase) { + t.Fatal("credential identity collapsed protocol or host case that Git compares exactly") + } +} + +func TestFormatCredentialURLRoundTripsStructuredFields(t *testing.T) { + want := credential{ + protocol: "HTTPS", + host: "GitLab.Example.COM:8443", + path: "group/a+b #?/repository.git", + username: "user+name@example.com", + password: "tok:en@/%+?", + } + + encoded, err := formatCredentialURL(want) + if err != nil { + t.Fatalf("format credential: %v", err) + } + if strings.Contains(encoded, want.password) { + t.Fatalf("special characters were not encoded in %q", encoded) + } + got := parseCredential(encoded) + if got == nil { + t.Fatalf("parse generated credential %q", encoded) + } + if *got != want { + t.Fatalf("round-trip credential = %+v, want %+v", *got, want) + } +} + +func TestDisplayCredentialNeverIncludesSecretOrControls(t *testing.T) { + display := displayCredential(credential{ + protocol: "https", + host: "example.com", + path: "org/\x1b[31mrepo", + username: "user", + password: "top-secret-token", + }) + if strings.Contains(display, "top-secret-token") { + t.Fatal("display leaked the credential secret") + } + if strings.ContainsRune(display, '\x1b') { + t.Fatal("display contains a terminal escape character") + } +} + +func TestCredentialInsertionIndexPlacesSpecificScopeFirst(t *testing.T) { + existing := []credential{ + {protocol: "https", host: "example.com", path: "unrelated", username: "user"}, + {protocol: "https", host: "example.com", path: "group", username: "user"}, + } + candidate := credential{ + protocol: "https", + host: "example.com", + path: "group/subgroup/repository.git", + username: "user", + } + if got := credentialInsertionIndex(existing, candidate); got != 1 { + t.Fatalf("insertion index = %d, want 1", got) + } + + candidate.path = "" + if got := credentialInsertionIndex(existing, candidate); got != len(existing) { + t.Fatalf("broad credential insertion index = %d, want %d", got, len(existing)) + } +} + +func TestCredentialInsertionIndexPrioritizesScopeWhenGitOmitsUsername(t *testing.T) { + broadOnly := []credential{ + {protocol: "https", host: "example.com", path: "group", username: "broad-user"}, + } + candidate := credential{ + protocol: "https", + host: "example.com", + path: "group/team/repository.git", + username: "different-user", + } + if got := credentialInsertionIndex(broadOnly, candidate); got != 0 { + t.Fatalf("insertion index = %d, want 0 before a broader credential", got) + } + + existing := []credential{ + {protocol: "https", host: "example.com", path: "group/repository.git", username: "existing-user"}, + broadOnly[0], + } + candidate.path = "group/repository.git" + if got := credentialInsertionIndex(existing, candidate); got != 1 { + t.Fatalf("equal-scope insertion index = %d, want 1 after equal scope and before broad scope", got) + } +} diff --git a/file_store.go b/file_store.go new file mode 100644 index 0000000..f8b0452 --- /dev/null +++ b/file_store.go @@ -0,0 +1,271 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "log" + "os" + "strings" +) + +const maxCredentialFileBytes = 16 * 1024 * 1024 + +type fileCredentialStore struct { + path string +} + +type credentialFileLine struct { + value string + ending string +} + +func newFileCredentialStore(path string) *fileCredentialStore { + return &fileCredentialStore{path: path} +} + +func (s *fileCredentialStore) Backend() credentialBackend { + return fileBackend +} + +func (s *fileCredentialStore) DisplayName() string { + return "Credential file" +} + +func (s *fileCredentialStore) List() ([]credentialRecord, error) { + lines, revision, err := s.readLines() + if err != nil { + return nil, err + } + + records := make([]credentialRecord, 0, len(lines)) + for position, line := range lines { + value := parseCredential(line.value) + if value == nil { + continue + } + value.password = "" + records = append(records, credentialRecord{ + id: fileCredentialRecordID(position, line.value), + backend: fileBackend, + credential: *value, + revision: revision, + position: position, + }) + } + return records, nil +} + +func (s *fileCredentialStore) Add(value credential) error { + value = normalizeCredentialForStorage(value) + if err := validateCredentialForStorage(value, true); err != nil { + return err + } + encoded, err := formatCredentialURL(value) + if err != nil { + return err + } + + lines, revision, err := s.readLines() + if err != nil { + return err + } + position, err := credentialFileInsertionPosition(lines, value) + if err != nil { + return err + } + lines = insertCredentialFileLine(lines, position, encoded) + return s.writeLinesIfUnchanged(lines, revision) +} + +func (s *fileCredentialStore) Update(record credentialRecord, value credential) error { + if record.backend != fileBackend { + return errors.New("credential does not belong to the file backend") + } + + lines, revision, err := s.readLines() + if err != nil { + return err + } + if revision != record.revision || record.position < 0 || record.position >= len(lines) { + return errCredentialChanged + } + + existing := parseCredential(lines[record.position].value) + if existing == nil || fileCredentialRecordID(record.position, lines[record.position].value) != record.id || + !sameCredentialMetadata(*existing, record.credential) { + return errCredentialChanged + } + + if value.password == "" { + value.password = existing.password + } + value = normalizeCredentialForStorage(value) + if err := validateCredentialForStorage(value, true); err != nil { + return err + } + encoded, err := formatCredentialURL(value) + if err != nil { + return err + } + + lines = append(lines[:record.position], lines[record.position+1:]...) + position, err := credentialFileInsertionPosition(lines, value) + if err != nil { + return err + } + lines = insertCredentialFileLine(lines, position, encoded) + return s.writeLinesIfUnchanged(lines, revision) +} + +func (s *fileCredentialStore) Delete(record credentialRecord) error { + if record.backend != fileBackend { + return errors.New("credential does not belong to the file backend") + } + + lines, revision, err := s.readLines() + if err != nil { + return err + } + if revision != record.revision || record.position < 0 || record.position >= len(lines) { + return errCredentialChanged + } + existing := parseCredential(lines[record.position].value) + if existing == nil || fileCredentialRecordID(record.position, lines[record.position].value) != record.id || + !sameCredentialMetadata(*existing, record.credential) { + return errCredentialChanged + } + + lines = append(lines[:record.position], lines[record.position+1:]...) + return s.writeLinesIfUnchanged(lines, revision) +} + +func (s *fileCredentialStore) Lookup(request *credential) (*credential, error) { + lines, _, err := s.readLines() + if err != nil { + return nil, err + } + for position, line := range lines { + value := parseCredential(line.value) + if value == nil { + log.Printf("ignore malformed credential at line %d", position+1) + continue + } + if value.match(request) { + return value, nil + } + } + return nil, nil +} + +func (s *fileCredentialStore) readLines() ([]credentialFileLine, string, error) { + data, err := readFileLimited(s.path, maxCredentialFileBytes) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, credentialFileRevision(nil), nil + } + return nil, "", fmt.Errorf("read credential file: %w", err) + } + return splitCredentialFileLines(data), credentialFileRevision(data), nil +} + +func (s *fileCredentialStore) writeLinesIfUnchanged(lines []credentialFileLine, revision string) error { + data := joinCredentialFileLines(lines) + if len(data) > maxCredentialFileBytes { + return fmt.Errorf("credential file exceeds %d bytes", maxCredentialFileBytes) + } + return writePrivateFileIfUnchanged(s.path, data, revision) +} + +func credentialFileRevision(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func fileCredentialRecordID(position int, value string) string { + digest := sha256.Sum256([]byte(value)) + return fmt.Sprintf("file:%d:%s", position, hex.EncodeToString(digest[:8])) +} + +func credentialFileInsertionPosition(lines []credentialFileLine, candidate credential) (int, error) { + existing := make([]credential, 0, len(lines)) + positions := make([]int, 0, len(lines)) + for position, line := range lines { + value := parseCredential(line.value) + if value == nil { + continue + } + if sameCredentialIdentity(*value, candidate) { + return 0, errDuplicateCredential + } + existing = append(existing, *value) + positions = append(positions, position) + } + + index := credentialInsertionIndex(existing, candidate) + if index < len(positions) { + return positions[index], nil + } + return len(lines), nil +} + +func splitCredentialFileLines(data []byte) []credentialFileLine { + if len(data) == 0 { + return nil + } + + remaining := string(data) + lines := make([]credentialFileLine, 0, strings.Count(remaining, "\n")+1) + for len(remaining) > 0 { + newline := strings.IndexByte(remaining, '\n') + if newline < 0 { + lines = append(lines, credentialFileLine{value: remaining}) + break + } + + value := remaining[:newline] + ending := "\n" + if strings.HasSuffix(value, "\r") { + value = strings.TrimSuffix(value, "\r") + ending = "\r\n" + } + lines = append(lines, credentialFileLine{value: value, ending: ending}) + remaining = remaining[newline+1:] + } + return lines +} + +func joinCredentialFileLines(lines []credentialFileLine) []byte { + var output strings.Builder + for _, line := range lines { + output.WriteString(line.value) + output.WriteString(line.ending) + } + return []byte(output.String()) +} + +func insertCredentialFileLine(lines []credentialFileLine, position int, value string) []credentialFileLine { + ending := "\n" + for _, line := range lines { + if line.ending != "" { + ending = line.ending + break + } + } + + if position < 0 { + position = 0 + } + if position > len(lines) { + position = len(lines) + } + if position == len(lines) && len(lines) > 0 && lines[len(lines)-1].ending == "" { + lines[len(lines)-1].ending = ending + } + + lines = append(lines, credentialFileLine{}) + copy(lines[position+1:], lines[position:]) + lines[position] = credentialFileLine{value: value, ending: ending} + return lines +} diff --git a/file_store_test.go b/file_store_test.go new file mode 100644 index 0000000..e4f5e4d --- /dev/null +++ b/file_store_test.go @@ -0,0 +1,298 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestFileCredentialStoreAddPreservesContentAndOrdersScopes(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + broad := "https://user:broad-token@gitlab.example.com/group" + original := "# preserved, although comments are not valid credential-store entries\r\n" + + broad + "\r\n" + + "malformed line that must not be discarded\r\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + store := newFileCredentialStore(path) + added := credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + password: "specific:@/+ token", + } + if err := store.Add(added); err != nil { + t.Fatalf("add credential: %v", err) + } + + encoded, err := formatCredentialURL(added) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "# preserved, although comments are not valid credential-store entries\r\n" + + encoded + "\r\n" + + broad + "\r\n" + + "malformed line that must not be discarded\r\n" + if string(data) != want { + t.Fatalf("credential file:\n%s\nwant:\n%s", data, want) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("credential file mode = %04o, want 0600", got) + } + } + + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } + for _, record := range records { + if record.credential.password != "" { + t.Fatal("listed file credential exposed its password") + } + } + + got, err := store.Lookup(&credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + }) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != added.password { + t.Fatalf("lookup = %+v, want the specific credential", got) + } +} + +func TestFileCredentialStoreRejectsDuplicate(t *testing.T) { + path := writeCredentialFile(t, "https://user:old-token@example.com/org/repo.git") + store := newFileCredentialStore(path) + err := store.Add(credential{ + protocol: "https", + host: "example.com", + path: "org/repo.git", + username: "user", + password: "new-token", + }) + if !errors.Is(err, errDuplicateCredential) { + t.Fatalf("error = %v, want duplicate credential error", err) + } +} + +func TestFileCredentialStoreUpdatePreservesSecretAndReorders(t *testing.T) { + path := writeCredentialFile(t, + "https://user:broad-token@example.com/group", + "https://user:preserved-token@example.com/other", + ) + store := newFileCredentialStore(path) + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } + + updated := records[1].credential + updated.path = "group/subgroup/repository.git" + updated.password = "" + if err := store.Update(records[1], updated); err != nil { + t.Fatalf("update credential: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("credential lines = %d, want 2", len(lines)) + } + first := parseCredential(lines[0]) + if first == nil || first.path != updated.path || first.password != "preserved-token" { + t.Fatalf("first credential = %+v, want reordered credential with preserved token", first) + } +} + +func TestFileCredentialStoreDetectsConcurrentModification(t *testing.T) { + path := writeCredentialFile(t, "https://user:token@example.com/org") + store := newFileCredentialStore(path) + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("https://user:changed@example.com/org\n"), 0o600); err != nil { + t.Fatal(err) + } + + err = store.Update(records[0], credential{ + protocol: "https", + host: "example.com", + path: "org", + username: "user", + }) + if !errors.Is(err, errCredentialChanged) { + t.Fatalf("error = %v, want concurrent modification error", err) + } + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if strings.Contains(string(data), "token") && !strings.Contains(string(data), "changed") { + t.Fatal("concurrent file content was overwritten") + } +} + +func TestFileCredentialStoreDeletePreservesOtherLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials") + contents := "malformed\nhttps://user:token@example.com/org\nhttps://other:keep@example.net/team\n" + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + store := newFileCredentialStore(path) + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if err := store.Delete(records[0]); err != nil { + t.Fatalf("delete credential: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "malformed\nhttps://other:keep@example.net/team\n" + if string(data) != want { + t.Fatalf("credential file = %q, want %q", data, want) + } +} + +func TestFileCredentialStoreFollowsExistingSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation commonly requires elevated privileges on Windows") + } + directory := t.TempDir() + target := filepath.Join(directory, "target") + link := filepath.Join(directory, "credentials") + if err := os.WriteFile(target, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + store := newFileCredentialStore(link) + if err := store.Add(credential{ + protocol: "https", + host: "example.com", + username: "user", + password: "token", + }); err != nil { + t.Fatalf("add through symlink: %v", err) + } + info, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("credential file symlink was replaced") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "example.com") { + t.Fatalf("symlink target was not updated: %q", data) + } +} + +func TestFileCredentialStoreOutputIsAcceptedByGitCredentialStore(t *testing.T) { + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git executable is unavailable") + } + path := filepath.Join(t.TempDir(), "credentials") + store := newFileCredentialStore(path) + want := credential{ + protocol: "HTTPS", + host: "GitLab.Example.COM:8443", + path: "group/a+b #?/repository.git", + username: "user+name@example.com", + password: "tok:en@/%+?", + } + if err := store.Add(want); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(gitPath, "credential-store", "--file", path, "get") + cmd.Stdin = strings.NewReader( + "protocol=" + want.protocol + "\n" + + "host=" + want.host + "\n" + + "path=" + want.path + "\n" + + "username=" + want.username + "\n\n", + ) + output, err := cmd.Output() + if err != nil { + t.Fatalf("git credential-store get: %v", err) + } + got, err := parseGitCredentialRequest(strings.NewReader(string(output))) + if err != nil { + t.Fatalf("parse git credential-store output: %v", err) + } + if got.username != want.username || got.password != want.password { + t.Fatalf("git credential-store returned username=%q password length=%d, want username=%q password length=%d", + got.username, len(got.password), want.username, len(want.password)) + } + + // Our writer must release the same temporary .lock path that Git's built-in + // credential-store uses, or Git would fail here with a locking error. + gitWritten := credential{ + protocol: "https", + host: "github.example.com", + path: "team/repository.git", + username: "git-user", + password: "git-written-token", + } + cmd = exec.Command(gitPath, "credential-store", "--file", path, "store") + cmd.Stdin = strings.NewReader( + "protocol=" + gitWritten.protocol + "\n" + + "host=" + gitWritten.host + "\n" + + "path=" + gitWritten.path + "\n" + + "username=" + gitWritten.username + "\n" + + "password=" + gitWritten.password + "\n\n", + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git credential-store store: %v: %s", err, output) + } + assertNoCredentialLockFile(t, path+".lock") + + gotWritten, err := store.Lookup(&gitWritten) + if err != nil { + t.Fatalf("lookup Git-written credential: %v", err) + } + if gotWritten == nil || gotWritten.password != gitWritten.password { + t.Fatal("credential written by Git was not readable by the file backend") + } +} diff --git a/go.mod b/go.mod index 019b0aa..13fa478 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,32 @@ module github.com/ttys3/git-credential-readonly -go 1.21 +go 1.25.0 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/sys v0.47.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ea66a7 --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= +github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keyring_store.go b/keyring_store.go new file mode 100644 index 0000000..ab690c6 --- /dev/null +++ b/keyring_store.go @@ -0,0 +1,663 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + oskeyring "github.com/zalando/go-keyring" +) + +const ( + keyringIndexVersion = 1 + keyringPayloadVersion = 1 + keyringServiceName = "git-credential-readonly" +) + +var keyringIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`) + +type keyringClient interface { + Set(service, account, secret string) error + Get(service, account string) (string, error) + Delete(service, account string) error +} + +type systemKeyringClient struct{} + +func (systemKeyringClient) Set(service, account, secret string) error { + return oskeyring.Set(service, account, secret) +} + +func (systemKeyringClient) Get(service, account string) (string, error) { + return oskeyring.Get(service, account) +} + +func (systemKeyringClient) Delete(service, account string) error { + return oskeyring.Delete(service, account) +} + +type keyringCredentialStore struct { + indexPath string + service string + client keyringClient + writeIndexOverride func(keyringIndex, string) error +} + +type keyringIndex struct { + Version int `json:"version"` + Credentials []keyringIndexCredential `json:"credentials"` + PendingDeletes []keyringIndexCredential `json:"pending_deletes,omitempty"` +} + +type keyringIndexCredential struct { + ID string `json:"id"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Path string `json:"path,omitempty"` + Username string `json:"username"` +} + +type keyringCredentialPayload struct { + Version int `json:"version"` + ID string `json:"id"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Path string `json:"path,omitempty"` + Username string `json:"username"` + Password string `json:"password"` +} + +func defaultKeyringIndexPath() (string, error) { + configDirectory, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("find user configuration directory: %w", err) + } + return filepath.Join(configDirectory, "git-credential-readonly", "keyring-index.json"), nil +} + +func newKeyringCredentialStore(indexPath string) *keyringCredentialStore { + return &keyringCredentialStore{ + indexPath: indexPath, + service: keyringServiceName, + client: systemKeyringClient{}, + } +} + +func (s *keyringCredentialStore) Backend() credentialBackend { + return keyringBackend +} + +func (s *keyringCredentialStore) DisplayName() string { + switch runtime.GOOS { + case "darwin": + return "macOS Keychain" + case "linux", "freebsd", "openbsd": + return "Secret Service" + case "windows": + return "Windows Credential Manager" + default: + return "System keyring" + } +} + +func (s *keyringCredentialStore) List() ([]credentialRecord, error) { + index, revision, err := s.readIndex() + if err != nil { + return nil, err + } + + records := make([]credentialRecord, 0, len(index.Credentials)) + for position, item := range index.Credentials { + records = append(records, credentialRecord{ + id: item.ID, + backend: keyringBackend, + credential: item.credential(), + revision: revision, + position: position, + }) + } + if len(index.PendingDeletes) > 0 { + return records, fmt.Errorf( + "%d obsolete keyring item(s) are awaiting secure cleanup; the next keyring change will retry", + len(index.PendingDeletes), + ) + } + return records, nil +} + +func (s *keyringCredentialStore) Add(value credential) error { + value = normalizeCredentialForStorage(value) + if err := validateCredentialForStorage(value, true); err != nil { + return err + } + + lockedPath, index, revision, err := s.lockIndex() + if err != nil { + return err + } + defer lockedPath.release() + index, revision, _ = s.cleanupPendingDeletesLocked(index, revision, lockedPath.path) + if findDuplicateCredential(index.credentials(), value) { + return errDuplicateCredential + } + + id, err := newKeyringCredentialID() + if err != nil { + return err + } + payload, err := marshalKeyringPayload(id, value) + if err != nil { + return err + } + if err := validateKeyringPayloadSize(payload); err != nil { + return err + } + if err := s.client.Set(s.service, id, payload); err != nil { + return friendlyKeyringError("store credential", err) + } + + index.insert(keyringIndexCredentialFrom(id, value)) + newRevision, err := s.persistIndexLocked(index, revision, lockedPath.path) + if err != nil { + rollbackErr := s.client.Delete(s.service, id) + if rollbackErr != nil && !errors.Is(rollbackErr, oskeyring.ErrNotFound) { + return errors.Join(err, friendlyKeyringError("roll back credential", rollbackErr)) + } + return err + } + _, _, _ = s.cleanupPendingDeletesLocked(index, newRevision, lockedPath.path) + return nil +} + +func (s *keyringCredentialStore) Update(record credentialRecord, value credential) error { + if record.backend != keyringBackend { + return errors.New("credential does not belong to the keyring backend") + } + + lockedPath, index, revision, err := s.lockIndex() + if err != nil { + return err + } + defer lockedPath.release() + if revision != record.revision { + return errCredentialChanged + } + position := index.find(record.id) + if position < 0 || !sameCredentialMetadata(index.Credentials[position].credential(), record.credential) { + return errCredentialChanged + } + index, revision, _ = s.cleanupPendingDeletesLocked(index, revision, lockedPath.path) + position = index.find(record.id) + + oldPayload, err := s.client.Get(s.service, record.id) + secretMissing := errors.Is(err, oskeyring.ErrNotFound) + if err != nil && !secretMissing { + return friendlyKeyringError("read credential for editing", err) + } + if secretMissing { + if value.password == "" { + return errors.New("keyring secret is missing; enter a replacement password or token, or delete the stale entry") + } + } else { + oldValue, err := unmarshalKeyringPayload(record.id, oldPayload) + if err != nil { + return err + } + if !sameCredentialMetadata(oldValue, record.credential) { + return errors.New("keyring credential metadata does not match its index") + } + if value.password == "" { + value.password = oldValue.password + } + } + value = normalizeCredentialForStorage(value) + if err := validateCredentialForStorage(value, true); err != nil { + return err + } + + remaining := append([]keyringIndexCredential(nil), index.Credentials[:position]...) + remaining = append(remaining, index.Credentials[position+1:]...) + remainingIndex := keyringIndex{ + Version: keyringIndexVersion, + Credentials: remaining, + PendingDeletes: append([]keyringIndexCredential(nil), index.PendingDeletes...), + } + if findDuplicateCredential(remainingIndex.credentials(), value) { + return errDuplicateCredential + } + + newID, err := newKeyringCredentialID() + if err != nil { + return err + } + newPayload, err := marshalKeyringPayload(newID, value) + if err != nil { + return err + } + if err := validateKeyringPayloadSize(newPayload); err != nil { + return err + } + if err := s.client.Set(s.service, newID, newPayload); err != nil { + return friendlyKeyringError("update credential", err) + } + + remainingIndex.insert(keyringIndexCredentialFrom(newID, value)) + if !secretMissing { + remainingIndex.PendingDeletes = append( + remainingIndex.PendingDeletes, + index.Credentials[position], + ) + } + newRevision, err := s.persistIndexLocked(remainingIndex, revision, lockedPath.path) + if err != nil { + rollbackErr := s.client.Delete(s.service, newID) + if errors.Is(rollbackErr, oskeyring.ErrNotFound) { + rollbackErr = nil + } + if rollbackErr != nil { + return errors.Join(err, friendlyKeyringError("roll back credential", rollbackErr)) + } + return err + } + _, _, _ = s.cleanupPendingDeletesLocked(remainingIndex, newRevision, lockedPath.path) + return nil +} + +func (s *keyringCredentialStore) Delete(record credentialRecord) error { + if record.backend != keyringBackend { + return errors.New("credential does not belong to the keyring backend") + } + + lockedPath, index, revision, err := s.lockIndex() + if err != nil { + return err + } + defer lockedPath.release() + if revision != record.revision { + return errCredentialChanged + } + position := index.find(record.id) + if position < 0 || !sameCredentialMetadata(index.Credentials[position].credential(), record.credential) { + return errCredentialChanged + } + index, revision, _ = s.cleanupPendingDeletesLocked(index, revision, lockedPath.path) + position = index.find(record.id) + + oldPayload, err := s.client.Get(s.service, record.id) + secretMissing := errors.Is(err, oskeyring.ErrNotFound) + if err != nil && !secretMissing { + return friendlyKeyringError("read credential before deletion", err) + } + if !secretMissing { + oldValue, err := unmarshalKeyringPayload(record.id, oldPayload) + if err != nil { + return err + } + if !sameCredentialMetadata(oldValue, record.credential) { + return errors.New("keyring credential metadata does not match its index") + } + } + + removedItem := index.Credentials[position] + index.Credentials = append(index.Credentials[:position], index.Credentials[position+1:]...) + if !secretMissing { + index.PendingDeletes = append(index.PendingDeletes, removedItem) + } + newRevision, err := s.persistIndexLocked(index, revision, lockedPath.path) + if err != nil { + return err + } + _, _, _ = s.cleanupPendingDeletesLocked(index, newRevision, lockedPath.path) + return nil +} + +func (s *keyringCredentialStore) Lookup(request *credential) (*credential, error) { + if request == nil { + return nil, nil + } + index, _, err := s.readIndex() + if err != nil { + return nil, err + } + + matchingPosition := -1 + matchingPathLength := -1 + for position, item := range index.Credentials { + metadata := item.credential() + // A path-scoped secret is not safe to return when Git omitted the path: + // multiple repositories on the same host would be indistinguishable. + if request.path == "" && metadata.path != "" { + continue + } + if !metadata.match(request) { + continue + } + if len(metadata.path) > matchingPathLength { + matchingPosition = position + matchingPathLength = len(metadata.path) + } + } + if matchingPosition < 0 { + return nil, nil + } + + item := index.Credentials[matchingPosition] + metadata := item.credential() + secret, err := s.client.Get(s.service, item.ID) + if err != nil { + return nil, friendlyKeyringError("read matching credential", err) + } + value, err := unmarshalKeyringPayload(item.ID, secret) + if err != nil { + return nil, err + } + if !sameCredentialMetadata(value, metadata) { + return nil, errors.New("keyring credential metadata does not match its index") + } + if !value.match(request) { + return nil, errors.New("keyring credential failed verified scope matching") + } + return &value, nil +} + +func (s *keyringCredentialStore) readIndex() (keyringIndex, string, error) { + return s.readIndexPath(s.indexPath) +} + +func (s *keyringCredentialStore) readIndexPath(path string) (keyringIndex, string, error) { + data, err := readFileLimited(path, maxCredentialFileBytes) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + index := keyringIndex{Version: keyringIndexVersion} + return index, credentialFileRevision(nil), nil + } + return keyringIndex{}, "", fmt.Errorf("read keyring index: %w", err) + } + if len(data) == 0 { + return keyringIndex{Version: keyringIndexVersion}, credentialFileRevision(nil), nil + } + var index keyringIndex + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&index); err != nil { + return keyringIndex{}, "", fmt.Errorf("decode keyring index: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return keyringIndex{}, "", errors.New("decode keyring index: trailing data") + } + if err := validateKeyringIndex(index); err != nil { + return keyringIndex{}, "", err + } + return index, credentialFileRevision(data), nil +} + +func (s *keyringCredentialStore) lockIndex() (*lockedPrivatePath, keyringIndex, string, error) { + lockedPath, err := lockPrivatePath(s.indexPath) + if err != nil { + return nil, keyringIndex{}, "", err + } + index, revision, err := s.readIndexPath(lockedPath.path) + if err != nil { + lockedPath.release() + return nil, keyringIndex{}, "", err + } + return lockedPath, index, revision, nil +} + +func (s *keyringCredentialStore) writeIndex(index keyringIndex) error { + data, err := marshalKeyringIndex(index) + if err != nil { + return err + } + if err := writePrivateFile(s.indexPath, data); err != nil { + return fmt.Errorf("write keyring index: %w", err) + } + return nil +} + +func (s *keyringCredentialStore) persistIndexLocked( + index keyringIndex, + expectedRevision, path string, +) (string, error) { + data, err := marshalKeyringIndex(index) + if err != nil { + return "", err + } + if s.writeIndexOverride != nil { + if err := s.writeIndexOverride(index, expectedRevision); err != nil { + return "", err + } + return credentialFileRevision(data), nil + } + if err := writePrivateFileIfUnchanged(path, data, expectedRevision); err != nil { + return "", fmt.Errorf("write keyring index: %w", err) + } + return credentialFileRevision(data), nil +} + +func marshalKeyringIndex(index keyringIndex) ([]byte, error) { + if err := validateKeyringIndex(index); err != nil { + return nil, err + } + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return nil, fmt.Errorf("encode keyring index: %w", err) + } + data = append(data, '\n') + if len(data) > maxCredentialFileBytes { + return nil, errors.New("keyring index is unexpectedly large") + } + return data, nil +} + +func (s *keyringCredentialStore) cleanupPendingDeletesLocked( + index keyringIndex, + revision, path string, +) (keyringIndex, string, error) { + if len(index.PendingDeletes) == 0 { + return index, revision, nil + } + + remaining := make([]keyringIndexCredential, 0, len(index.PendingDeletes)) + removed := false + var cleanupErrors []error + for _, item := range index.PendingDeletes { + secret, err := s.client.Get(s.service, item.ID) + if errors.Is(err, oskeyring.ErrNotFound) { + removed = true + continue + } + if err != nil { + remaining = append(remaining, item) + cleanupErrors = append(cleanupErrors, friendlyKeyringError("read obsolete credential", err)) + continue + } + + value, err := unmarshalKeyringPayload(item.ID, secret) + if err != nil || !sameCredentialMetadata(value, item.credential()) { + remaining = append(remaining, item) + cleanupErrors = append(cleanupErrors, errors.New("obsolete keyring credential metadata does not match its index")) + continue + } + if err := s.client.Delete(s.service, item.ID); err != nil && !errors.Is(err, oskeyring.ErrNotFound) { + remaining = append(remaining, item) + cleanupErrors = append(cleanupErrors, friendlyKeyringError("delete obsolete credential", err)) + continue + } + removed = true + } + + index.PendingDeletes = remaining + if !removed { + return index, revision, errors.Join(cleanupErrors...) + } + newRevision, err := s.persistIndexLocked(index, revision, path) + if err != nil { + cleanupErrors = append(cleanupErrors, err) + return index, revision, errors.Join(cleanupErrors...) + } + return index, newRevision, errors.Join(cleanupErrors...) +} + +func validateKeyringIndex(index keyringIndex) error { + if index.Version != keyringIndexVersion { + return fmt.Errorf("unsupported keyring index version %d", index.Version) + } + ids := make(map[string]struct{}, len(index.Credentials)) + identities := make(map[credentialIdentity]struct{}, len(index.Credentials)) + for _, item := range index.Credentials { + if !keyringIDPattern.MatchString(item.ID) { + return errors.New("keyring index contains an invalid credential ID") + } + if _, exists := ids[item.ID]; exists { + return errors.New("keyring index contains a duplicate credential ID") + } + ids[item.ID] = struct{}{} + + value := item.credential() + if err := validateCredentialForStorage(value, false); err != nil { + return fmt.Errorf("keyring index contains invalid credential metadata: %w", err) + } + identity := credentialIdentityOf(value) + if _, exists := identities[identity]; exists { + return errDuplicateCredential + } + identities[identity] = struct{}{} + } + for _, item := range index.PendingDeletes { + if !keyringIDPattern.MatchString(item.ID) { + return errors.New("keyring index contains an invalid pending-delete ID") + } + if _, exists := ids[item.ID]; exists { + return errors.New("keyring index reuses a credential ID") + } + ids[item.ID] = struct{}{} + if err := validateCredentialForStorage(item.credential(), false); err != nil { + return fmt.Errorf("keyring index contains invalid pending-delete metadata: %w", err) + } + } + return nil +} + +func (index keyringIndex) credentials() []credential { + values := make([]credential, 0, len(index.Credentials)) + for _, item := range index.Credentials { + values = append(values, item.credential()) + } + return values +} + +func (index *keyringIndex) insert(item keyringIndexCredential) { + position := credentialInsertionIndex(index.credentials(), item.credential()) + index.Credentials = append(index.Credentials, keyringIndexCredential{}) + copy(index.Credentials[position+1:], index.Credentials[position:]) + index.Credentials[position] = item +} + +func (index keyringIndex) find(id string) int { + for i, item := range index.Credentials { + if item.ID == id { + return i + } + } + return -1 +} + +func (item keyringIndexCredential) credential() credential { + return credential{ + protocol: item.Protocol, + host: item.Host, + path: item.Path, + username: item.Username, + } +} + +func keyringIndexCredentialFrom(id string, value credential) keyringIndexCredential { + return keyringIndexCredential{ + ID: id, + Protocol: value.protocol, + Host: value.host, + Path: value.path, + Username: value.username, + } +} + +func newKeyringCredentialID() (string, error) { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + return "", fmt.Errorf("generate credential ID: %w", err) + } + return hex.EncodeToString(id[:]), nil +} + +func marshalKeyringPayload(id string, value credential) (string, error) { + payload := keyringCredentialPayload{ + Version: keyringPayloadVersion, + ID: id, + Protocol: value.protocol, + Host: value.host, + Path: value.path, + Username: value.username, + Password: value.password, + } + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode keyring credential: %w", err) + } + return string(data), nil +} + +func validateKeyringPayloadSize(payload string) error { + // go-keyring documents an approximately 3 KiB combined limit on macOS and + // a 2560-byte secret limit on Windows. Leave room for the service/account + // identifiers and platform encoding overhead. + if (runtime.GOOS == "darwin" || runtime.GOOS == "windows") && len(payload) > 2400 { + return errors.New("credential is too large for the system keyring") + } + return nil +} + +func unmarshalKeyringPayload(expectedID, secret string) (credential, error) { + var payload keyringCredentialPayload + decoder := json.NewDecoder(strings.NewReader(secret)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + return credential{}, fmt.Errorf("decode keyring credential: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return credential{}, errors.New("decode keyring credential: trailing data") + } + if payload.Version != keyringPayloadVersion || payload.ID != expectedID { + return credential{}, errors.New("keyring credential has invalid identity or version") + } + value := normalizeCredentialForStorage(credential{ + protocol: payload.Protocol, + host: payload.Host, + path: payload.Path, + username: payload.Username, + password: payload.Password, + }) + if err := validateCredentialForStorage(value, true); err != nil { + return credential{}, fmt.Errorf("keyring credential is invalid: %w", err) + } + return value, nil +} + +func friendlyKeyringError(action string, err error) error { + if err == nil { + return nil + } + if runtime.GOOS == "linux" || runtime.GOOS == "freebsd" || runtime.GOOS == "openbsd" { + return fmt.Errorf("%s in Secret Service: %w (ensure a Secret Service provider and D-Bus session are available)", action, err) + } + return fmt.Errorf("%s in system keyring: %w", action, err) +} diff --git a/keyring_store_test.go b/keyring_store_test.go new file mode 100644 index 0000000..2d9fc20 --- /dev/null +++ b/keyring_store_test.go @@ -0,0 +1,684 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + oskeyring "github.com/zalando/go-keyring" +) + +type fakeKeyringClient struct { + mu sync.Mutex + values map[string]string + setErr error + getErr error + deleteErr error +} + +func newFakeKeyringClient() *fakeKeyringClient { + return &fakeKeyringClient{values: make(map[string]string)} +} + +func (f *fakeKeyringClient) Set(service, account, secret string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.setErr != nil { + return f.setErr + } + f.values[service+"\x00"+account] = secret + return nil +} + +func (f *fakeKeyringClient) Get(service, account string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErr != nil { + return "", f.getErr + } + secret, ok := f.values[service+"\x00"+account] + if !ok { + return "", oskeyring.ErrNotFound + } + return secret, nil +} + +func (f *fakeKeyringClient) Delete(service, account string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteErr != nil { + return f.deleteErr + } + key := service + "\x00" + account + if _, ok := f.values[key]; !ok { + return oskeyring.ErrNotFound + } + delete(f.values, key) + return nil +} + +func newTestKeyringStore(t *testing.T) (*keyringCredentialStore, *fakeKeyringClient) { + t.Helper() + client := newFakeKeyringClient() + store := &keyringCredentialStore{ + indexPath: filepath.Join(t.TempDir(), "keyring-index.json"), + service: "test-git-credential-readonly", + client: client, + } + return store, client +} + +func TestKeyringCredentialStoreKeepsSecretsOutOfIndex(t *testing.T) { + store, client := newTestKeyringStore(t) + broad := credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group", + username: "user", + password: "broad-secret-token", + } + specific := credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + password: "specific-secret-token", + } + if err := store.Add(broad); err != nil { + t.Fatalf("add broad credential: %v", err) + } + if err := store.Add(specific); err != nil { + t.Fatalf("add specific credential: %v", err) + } + + indexData, err := os.ReadFile(store.indexPath) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{broad.password, specific.password} { + if strings.Contains(string(indexData), secret) { + t.Fatalf("keyring index contains secret %q", secret) + } + } + if runtime.GOOS != "windows" { + info, err := os.Stat(store.indexPath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("keyring index mode = %04o, want 0600", got) + } + } + + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 2 { + t.Fatalf("record count = %d, want 2", len(records)) + } + if records[0].credential.path != specific.path { + t.Fatalf("first path = %q, want specific path %q", records[0].credential.path, specific.path) + } + for _, record := range records { + if record.credential.password != "" { + t.Fatal("listed keyring credential exposed its password") + } + } + if len(client.values) != 2 { + t.Fatalf("stored keyring item count = %d, want 2", len(client.values)) + } + + got, err := store.Lookup(&credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + }) + if err != nil { + t.Fatalf("lookup keyring credential: %v", err) + } + if got == nil || got.password != specific.password { + t.Fatalf("lookup = %+v, want specific credential", got) + } +} + +func TestKeyringCredentialStoreRejectsTamperedIndexRouting(t *testing.T) { + store, _ := newTestKeyringStore(t) + value := credential{ + protocol: "https", + host: "github.com", + path: "trusted/repository.git", + username: "user", + password: "must-not-leak", + } + if err := store.Add(value); err != nil { + t.Fatal(err) + } + + index, _, err := store.readIndex() + if err != nil { + t.Fatal(err) + } + index.Credentials[0].Host = "evil.example.com" + if err := store.writeIndex(index); err != nil { + t.Fatal(err) + } + + got, err := store.Lookup(&credential{ + protocol: "https", + host: "evil.example.com", + path: "trusted/repository.git", + username: "user", + }) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("error = %v, want metadata mismatch", err) + } + if got != nil { + t.Fatalf("tampered index returned credential: %+v", got) + } +} + +func TestKeyringCredentialStoreSelectsMostSpecificScopeEvenIfIndexIsReordered(t *testing.T) { + store, _ := newTestKeyringStore(t) + broad := credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group", + username: "user", + password: "broad-secret", + } + specific := credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + password: "specific-secret", + } + if err := store.Add(broad); err != nil { + t.Fatal(err) + } + if err := store.Add(specific); err != nil { + t.Fatal(err) + } + index, _, err := store.readIndex() + if err != nil { + t.Fatal(err) + } + if len(index.Credentials) != 2 { + t.Fatalf("credential count = %d, want 2", len(index.Credentials)) + } + index.Credentials[0], index.Credentials[1] = index.Credentials[1], index.Credentials[0] + if err := store.writeIndex(index); err != nil { + t.Fatal(err) + } + + got, err := store.Lookup(&credential{ + protocol: "https", + host: "gitlab.example.com", + path: "group/subgroup/repository.git", + username: "user", + }) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != specific.password { + t.Fatalf("lookup = %+v, want most-specific credential", got) + } +} + +func TestKeyringCredentialStoreRequiresRequestPathForScopedSecrets(t *testing.T) { + store, _ := newTestKeyringStore(t) + scoped := credential{ + protocol: "https", + host: "example.com", + path: "organization/repository.git", + username: "user", + password: "scoped-secret", + } + if err := store.Add(scoped); err != nil { + t.Fatal(err) + } + request := &credential{protocol: "https", host: "example.com", username: "user"} + got, err := store.Lookup(request) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("pathless request received scoped credential: %+v", got) + } + + hostWide := scoped + hostWide.path = "" + hostWide.password = "host-wide-secret" + if err := store.Add(hostWide); err != nil { + t.Fatal(err) + } + got, err = store.Lookup(request) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != hostWide.password { + t.Fatalf("pathless lookup = %+v, want host-wide credential", got) + } +} + +func TestKeyringCredentialStoreUpdatePreservesSecretAndDeleteRemovesIt(t *testing.T) { + store, client := newTestKeyringStore(t) + value := credential{ + protocol: "https", + host: "example.com", + path: "org", + username: "user", + password: "preserved-secret", + } + if err := store.Add(value); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + + updated := records[0].credential + updated.path = "org/team/repository.git" + updated.password = "" + if err := store.Update(records[0], updated); err != nil { + t.Fatalf("update credential: %v", err) + } + got, err := store.Lookup(&credential{ + protocol: "https", + host: "example.com", + path: updated.path, + username: "user", + }) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != value.password { + t.Fatalf("updated credential = %+v, want preserved password", got) + } + + records, err = store.List() + if err != nil { + t.Fatal(err) + } + if err := store.Delete(records[0]); err != nil { + t.Fatalf("delete credential: %v", err) + } + if len(client.values) != 0 { + t.Fatalf("keyring still contains %d item(s)", len(client.values)) + } + records, err = store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 0 { + t.Fatalf("record count after deletion = %d, want 0", len(records)) + } +} + +func TestKeyringCredentialStoreCanRemoveAndRepairMissingSecret(t *testing.T) { + store, client := newTestKeyringStore(t) + value := credential{ + protocol: "https", + host: "example.com", + path: "org", + username: "user", + password: "original-secret", + } + if err := store.Add(value); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + delete(client.values, store.service+"\x00"+records[0].id) + + err = store.Update(records[0], records[0].credential) + if err == nil || !strings.Contains(err.Error(), "secret is missing") { + t.Fatalf("error = %v, want missing secret guidance", err) + } + + replacement := records[0].credential + replacement.password = "replacement-secret" + if err := store.Update(records[0], replacement); err != nil { + t.Fatalf("repair missing keyring secret: %v", err) + } + got, err := store.Lookup(&credential{ + protocol: "https", + host: "example.com", + path: "org", + username: "user", + }) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != replacement.password { + t.Fatalf("repaired credential = %+v, want replacement secret", got) + } + + records, err = store.List() + if err != nil { + t.Fatal(err) + } + delete(client.values, store.service+"\x00"+records[0].id) + if err := store.Delete(records[0]); err != nil { + t.Fatalf("remove stale keyring index entry: %v", err) + } + records, err = store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 0 { + t.Fatalf("stale keyring records = %d, want 0", len(records)) + } +} + +func TestKeyringCredentialStoreDetectsConcurrentIndexChange(t *testing.T) { + store, _ := newTestKeyringStore(t) + first := credential{protocol: "https", host: "one.example.com", username: "user", password: "one"} + second := credential{protocol: "https", host: "two.example.com", username: "user", password: "two"} + if err := store.Add(first); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + if err := store.Add(second); err != nil { + t.Fatal(err) + } + + err = store.Update(records[0], records[0].credential) + if !errors.Is(err, errCredentialChanged) { + t.Fatalf("error = %v, want concurrent index change error", err) + } +} + +func TestKeyringCredentialStoreSerializesConcurrentUpdates(t *testing.T) { + store, client := newTestKeyringStore(t) + original := credential{ + protocol: "https", + host: "example.com", + path: "original", + username: "user", + password: "preserved-secret", + } + if err := store.Add(original); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + record := records[0] + + start := make(chan struct{}) + results := make(chan error, 2) + var updates sync.WaitGroup + for _, path := range []string{"first/repository.git", "second/repository.git"} { + path := path + updates.Add(1) + go func() { + defer updates.Done() + <-start + value := record.credential + value.path = path + results <- store.Update(record, value) + }() + } + close(start) + updates.Wait() + close(results) + + successes := 0 + changed := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, errCredentialChanged): + changed++ + default: + t.Fatalf("unexpected update error: %v", err) + } + } + if successes != 1 || changed != 1 { + t.Fatalf("successes = %d, changed = %d; want 1 and 1", successes, changed) + } + + records, err = store.List() + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("record count = %d, want 1", len(records)) + } + got, err := store.Lookup(&records[0].credential) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != original.password { + t.Fatalf("winning credential = %+v, want preserved secret", got) + } + client.mu.Lock() + keyringItems := len(client.values) + client.mu.Unlock() + if keyringItems != 1 { + t.Fatalf("keyring item count = %d, want 1", keyringItems) + } +} + +func TestKeyringCredentialStoreRollsBackSecretWhenIndexWriteFails(t *testing.T) { + store, client := newTestKeyringStore(t) + store.writeIndexOverride = func(keyringIndex, string) error { + return errors.New("simulated index failure") + } + err := store.Add(credential{ + protocol: "https", + host: "example.com", + username: "user", + password: "secret", + }) + if err == nil || !strings.Contains(err.Error(), "simulated index failure") { + t.Fatalf("error = %v, want simulated index failure", err) + } + if len(client.values) != 0 { + t.Fatalf("keyring contains %d orphaned item(s) after rollback", len(client.values)) + } +} + +func TestKeyringCredentialStoreUpdateFailureLeavesOriginalUsable(t *testing.T) { + store, client := newTestKeyringStore(t) + original := credential{ + protocol: "https", + host: "example.com", + path: "original/repository.git", + username: "user", + password: "original-secret", + } + if err := store.Add(original); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + store.writeIndexOverride = func(keyringIndex, string) error { + return errors.New("simulated index failure") + } + updated := records[0].credential + updated.path = "updated/repository.git" + updated.password = "replacement-secret" + if err := store.Update(records[0], updated); err == nil || + !strings.Contains(err.Error(), "simulated index failure") { + t.Fatalf("update error = %v, want index failure", err) + } + store.writeIndexOverride = nil + + got, err := store.Lookup(&credential{ + protocol: original.protocol, + host: original.host, + path: original.path, + username: original.username, + }) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != original.password { + t.Fatalf("original credential = %+v, want usable original", got) + } + client.mu.Lock() + keyringItems := len(client.values) + client.mu.Unlock() + if keyringItems != 1 { + t.Fatalf("keyring item count = %d, want only original", keyringItems) + } +} + +func TestKeyringCredentialStoreTracksAndRetriesFailedCleanup(t *testing.T) { + store, client := newTestKeyringStore(t) + original := credential{ + protocol: "https", + host: "example.com", + path: "original/repository.git", + username: "user", + password: "original-secret", + } + if err := store.Add(original); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + client.deleteErr = errors.New("simulated keyring cleanup failure") + updated := records[0].credential + updated.path = "updated/repository.git" + if err := store.Update(records[0], updated); err != nil { + t.Fatalf("commit update with deferred cleanup: %v", err) + } + + records, err = store.List() + if err == nil || !strings.Contains(err.Error(), "awaiting secure cleanup") { + t.Fatalf("list error = %v, want cleanup warning", err) + } + if len(records) != 1 || records[0].credential.path != updated.path { + t.Fatalf("active records = %+v, want updated credential", records) + } + got, lookupErr := store.Lookup(&records[0].credential) + if lookupErr != nil { + t.Fatal(lookupErr) + } + if got == nil || got.password != original.password { + t.Fatalf("updated credential = %+v, want preserved secret", got) + } + client.mu.Lock() + itemsBeforeRetry := len(client.values) + client.deleteErr = nil + client.mu.Unlock() + if itemsBeforeRetry != 2 { + t.Fatalf("items before retry = %d, want active and obsolete items", itemsBeforeRetry) + } + + if err := store.Add(credential{ + protocol: "https", + host: "another.example.com", + username: "other", + password: "other-secret", + }); err != nil { + t.Fatalf("add credential while retrying cleanup: %v", err) + } + index, _, err := store.readIndex() + if err != nil { + t.Fatal(err) + } + if len(index.PendingDeletes) != 0 { + t.Fatalf("pending deletes = %d, want 0 after retry", len(index.PendingDeletes)) + } + client.mu.Lock() + itemsAfterRetry := len(client.values) + client.mu.Unlock() + if itemsAfterRetry != 2 { + t.Fatalf("items after retry = %d, want two active credentials", itemsAfterRetry) + } +} + +func TestKeyringCredentialStoreDoesNotDeleteTamperedPendingItem(t *testing.T) { + store, client := newTestKeyringStore(t) + original := credential{ + protocol: "https", + host: "example.com", + path: "original", + username: "user", + password: "original-secret", + } + if err := store.Add(original); err != nil { + t.Fatal(err) + } + records, err := store.List() + if err != nil { + t.Fatal(err) + } + client.deleteErr = errors.New("defer cleanup") + updated := records[0].credential + updated.path = "updated" + if err := store.Update(records[0], updated); err != nil { + t.Fatal(err) + } + client.deleteErr = nil + + index, _, err := store.readIndex() + if err != nil { + t.Fatal(err) + } + if len(index.PendingDeletes) != 1 { + t.Fatalf("pending deletes = %d, want 1", len(index.PendingDeletes)) + } + pendingID := index.PendingDeletes[0].ID + index.PendingDeletes[0].Host = "tampered.example.com" + if err := store.writeIndex(index); err != nil { + t.Fatal(err) + } + + if err := store.Add(credential{ + protocol: "https", + host: "another.example.com", + username: "other", + password: "other-secret", + }); err != nil { + t.Fatal(err) + } + if _, err := client.Get(store.service, pendingID); err != nil { + t.Fatalf("tampered pending item was removed: %v", err) + } + index, _, err = store.readIndex() + if err != nil { + t.Fatal(err) + } + if len(index.PendingDeletes) != 1 { + t.Fatalf("pending deletes = %d, want tampered item retained", len(index.PendingDeletes)) + } +} + +func TestKeyringCredentialPayloadRejectsTrailingData(t *testing.T) { + id := strings.Repeat("a", 32) + payload, err := marshalKeyringPayload(id, credential{ + protocol: "https", + host: "example.com", + username: "user", + password: "secret", + }) + if err != nil { + t.Fatal(err) + } + if _, err := unmarshalKeyringPayload(id, payload+` {}`); err == nil { + t.Fatal("trailing JSON data was accepted") + } +} diff --git a/main.go b/main.go index c5aa8e9..1164b85 100644 --- a/main.go +++ b/main.go @@ -21,10 +21,14 @@ func main() { var credFile string var logFile string var debug bool + var backend string + var keyringIndex string flag.StringVar(&credFile, "file", defaultCredentialFile, "use given file instead of the default credential file") flag.StringVar(&logFile, "log", defaultLogFile, "log file path, used only when debug mode is enabled") flag.BoolVar(&debug, "debug", false, "enable debug mode and write log to "+defaultLogFile) + flag.StringVar(&backend, "backend", string(fileBackend), "credential lookup backend: file, keyring, or auto") + flag.StringVar(&keyringIndex, "keyring-index", "", "keyring metadata index path (defaults to the user config directory)") flag.Parse() log.SetFlags(log.LstdFlags | log.Lshortfile) @@ -62,13 +66,39 @@ func main() { log.Fatalf("get stdin failed, err=%v", err) } logCredentialMetadata("get request", req) - credential := getCredential(req, credFile) + credential, err := lookupCredential(req, backend, credFile, keyringIndex) + if err != nil { + log.Printf("credential lookup failed: %v", err) + os.Exit(1) + } if credential == nil { // credential not found os.Exit(1) } logCredentialMetadata("get credential success", credential) fmt.Printf("username=%s\npassword=%s\n", credential.username, credential.password) + case "manage", "tui": + var stores []managedCredentialStore + var storeWarnings []error + + credPath, err := expandHomeDir(credFile) + if err != nil { + storeWarnings = append(storeWarnings, fmt.Errorf("credential file is unavailable: %w", err)) + } else { + stores = append(stores, newFileCredentialStore(credPath)) + } + indexPath, err := resolveKeyringIndexPath(keyringIndex) + if err != nil { + storeWarnings = append(storeWarnings, fmt.Errorf("system keyring is unavailable: %w", err)) + } else { + // The manager recommends secure storage, so show it before the + // compatibility file backend when both are available. + stores = append([]managedCredentialStore{newKeyringCredentialStore(indexPath)}, stores...) + } + if err := runCredentialManager(stores, storeWarnings...); err != nil { + fmt.Fprintf(os.Stderr, "credential manager failed: %s\n", safeTerminalText(err.Error())) + os.Exit(1) + } case "erase", "store": log.Printf("ignore action=%v", action) // noop @@ -77,6 +107,52 @@ func main() { } } +func lookupCredential(request *credential, backend, credFile, keyringIndex string) (*credential, error) { + switch backend { + case string(fileBackend): + credPath, err := expandHomeDir(credFile) + if err != nil { + return nil, err + } + return newFileCredentialStore(credPath).Lookup(request) + case string(keyringBackend): + indexPath, err := resolveKeyringIndexPath(keyringIndex) + if err != nil { + return nil, err + } + return newKeyringCredentialStore(indexPath).Lookup(request) + case "auto": + indexPath, err := resolveKeyringIndexPath(keyringIndex) + var value *credential + var keyringErr error + if err == nil { + value, keyringErr = newKeyringCredentialStore(indexPath).Lookup(request) + } else { + keyringErr = err + } + if keyringErr == nil && value != nil { + return value, nil + } + if keyringErr != nil { + log.Printf("keyring lookup failed; falling back to file: %v", keyringErr) + } + credPath, err := expandHomeDir(credFile) + if err != nil { + return nil, err + } + return newFileCredentialStore(credPath).Lookup(request) + default: + return nil, fmt.Errorf("unsupported credential backend %q (want file, keyring, or auto)", backend) + } +} + +func resolveKeyringIndexPath(path string) (string, error) { + if path == "" { + return defaultKeyringIndexPath() + } + return expandHomeDir(path) +} + type credential struct { protocol string username string @@ -230,9 +306,10 @@ func parseCredential(line string) *credential { path = trimCredentialURLPath(path) } - if strings.Contains(username, "\n") || strings.Contains(password, "\n") || - strings.Contains(host, "\n") || strings.Contains(path, "\n") { - return nil + for _, field := range []string{proto, username, password, host, path} { + if validateCredentialText("credential field", field, true) != nil { + return nil + } } return &credential{ @@ -308,36 +385,15 @@ func hexValue(value byte) (byte, bool) { func getCredential(req *credential, credFile string) *credential { credPath, err := expandHomeDir(credFile) if err != nil { - log.Fatal(err) + log.Printf("expand credential file path: %v", err) + return nil } - - file, err := os.Open(credPath) + value, err := newFileCredentialStore(credPath).Lookup(req) if err != nil { - // credential file not found or other error + log.Printf("read credential file: %v", err) return nil } - defer file.Close() - - scanner := bufio.NewScanner(file) - lineNumber := 0 - for scanner.Scan() { - lineNumber++ - line := scanner.Text() - cred := parseCredential(line) - if cred == nil { - log.Printf("ignore malformed credential at line %d", lineNumber) - continue - } - if cred.match(req) { - return cred - } - } - - if err := scanner.Err(); err != nil { - log.Fatal(err) - } - - return nil + return value } func expandHomeDir(path string) (string, error) { diff --git a/main_test.go b/main_test.go index 91436a0..f7c0d1e 100644 --- a/main_test.go +++ b/main_test.go @@ -142,6 +142,32 @@ func TestGetCredential(t *testing.T) { } } +func TestLookupCredentialAutoFallsBackToFile(t *testing.T) { + credFile := writeCredentialFile(t, "https://user:file-secret@example.com/org/repository.git") + missingIndex := filepath.Join(t.TempDir(), "missing-keyring-index.json") + request := &credential{ + protocol: "https", + host: "example.com", + path: "org/repository.git", + username: "user", + } + + got, err := lookupCredential(request, "auto", credFile, missingIndex) + if err != nil { + t.Fatal(err) + } + if got == nil || got.password != "file-secret" { + t.Fatalf("auto lookup = %+v, want file fallback", got) + } +} + +func TestLookupCredentialRejectsUnsupportedBackend(t *testing.T) { + _, err := lookupCredential(&credential{}, "unknown", "unused", "unused") + if err == nil || !strings.Contains(err.Error(), "unsupported credential backend") { + t.Fatalf("error = %v, want unsupported backend", err) + } +} + func TestGetCredentialNestedPathScopes(t *testing.T) { credFile := writeCredentialFile(t, "https://USERNAME:TOKEN1@gitlab.com/group/subgroup1/project.git", @@ -491,9 +517,15 @@ func TestParseCredentialMatchesGitPercentDecoding(t *testing.T) { } } -func TestParseCredentialRejectsEncodedNewline(t *testing.T) { - if got := parseCredential("https://user:token%0Asecret@gitlab.com/group/repo.git"); got != nil { - t.Fatalf("unexpected credential: %+v", got) +func TestParseCredentialRejectsEncodedControls(t *testing.T) { + for _, line := range []string{ + "https://user:token%0Asecret@gitlab.com/group/repo.git", + "https://user%0Dpassword=attacker:token@gitlab.com/group/repo.git", + "https://user:token%1B%5B31m@gitlab.com/group/repo.git", + } { + if got := parseCredential(line); got != nil { + t.Fatalf("unexpected credential for %q: %+v", line, got) + } } } diff --git a/private_file_lock_unix.go b/private_file_lock_unix.go new file mode 100644 index 0000000..1ce1e69 --- /dev/null +++ b/private_file_lock_unix.go @@ -0,0 +1,25 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + + "golang.org/x/sys/unix" +) + +func tryLockPrivateFile(file *os.File) (bool, error) { + err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + return true, nil + } + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return false, nil + } + return false, err +} + +func unlockPrivateFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_UN) +} diff --git a/private_file_lock_windows.go b/private_file_lock_windows.go new file mode 100644 index 0000000..dba1b5e --- /dev/null +++ b/private_file_lock_windows.go @@ -0,0 +1,34 @@ +//go:build windows + +package main + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLockPrivateFile(file *os.File) (bool, error) { + var overlapped windows.Overlapped + err := windows.LockFileEx( + windows.Handle(file.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &overlapped, + ) + if err == nil { + return true, nil + } + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return false, err +} + +func unlockPrivateFile(file *os.File) error { + var overlapped windows.Overlapped + return windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, &overlapped) +} diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..208a31e --- /dev/null +++ b/tui.go @@ -0,0 +1,877 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +const ( + managerDefaultWidth = 80 + managerDefaultHeight = 24 + managerFieldCount = 5 +) + +type managerScreen uint8 + +const ( + managerListScreen managerScreen = iota + managerActionScreen + managerBackendScreen + managerEditorScreen + managerSaveConfirmScreen + managerDeleteConfirmScreen +) + +type managerItemKind uint8 + +const ( + managerCredentialItem managerItemKind = iota + managerAddItem + managerQuitItem +) + +type managerListItem struct { + kind managerItemKind + record credentialRecord + title string + description string +} + +func (i managerListItem) Title() string { return i.title } +func (i managerListItem) Description() string { return i.description } +func (i managerListItem) FilterValue() string { return i.title + " " + i.description } + +type credentialManagerModel struct { + stores []managedCredentialStore + + screen managerScreen + list list.Model + width int + height int + + selectedRecord credentialRecord + menuIndex int + backendIndex int + + editorInputs []textinput.Model + editorField int + editorCreating bool + editorStore managedCredentialStore + editorError string + + pendingListStatus string +} + +var ( + managerTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("205")) + managerSubtitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + managerSelectedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("212")) + managerErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + managerWarningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("214")) + managerHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) +) + +func runCredentialManager(stores []managedCredentialStore, startupWarnings ...error) error { + if len(stores) == 0 && len(startupWarnings) > 0 { + return fmt.Errorf("no credential storage backend is available: %w", errors.Join(startupWarnings...)) + } + model, err := newCredentialManagerModel(stores) + if err != nil { + return err + } + if len(startupWarnings) > 0 { + warning := safeErrorText(errors.Join(startupWarnings...)) + if model.pendingListStatus != "" { + model.pendingListStatus += " Warning: " + warning + } else { + model.pendingListStatus = "Warning: " + warning + } + } + defer model.clearEditor() + + _, err = tea.NewProgram( + model, + tea.WithInput(os.Stdin), + tea.WithOutput(os.Stderr), + ).Run() + if errors.Is(err, tea.ErrInterrupted) { + return nil + } + return err +} + +func newCredentialManagerModel(stores []managedCredentialStore) (*credentialManagerModel, error) { + if len(stores) == 0 { + return nil, errors.New("no credential storage backend is configured") + } + + delegate := list.NewDefaultDelegate() + delegate.SetSpacing(0) + credentialList := list.New(nil, delegate, managerDefaultWidth, managerDefaultHeight) + credentialList.Title = "Git credential manager" + credentialList.SetStatusBarItemName("entry", "entries") + credentialList.StatusMessageLifetime = 5 * time.Second + credentialList.KeyMap.Quit = key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "quit"), + ) + credentialList.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open")), + } + } + + model := &credentialManagerModel{ + stores: stores, + screen: managerListScreen, + list: credentialList, + width: managerDefaultWidth, + height: managerDefaultHeight, + } + model.pendingListStatus = model.reloadCredentialList() + return model, nil +} + +func (m *credentialManagerModel) Init() tea.Cmd { + return m.showPendingListStatus() +} + +func (m *credentialManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = max(msg.Width, 1) + m.height = max(msg.Height, 1) + m.list.SetSize(m.width, m.height) + m.resizeEditorInputs() + return m, nil + case tea.InterruptMsg: + m.clearEditor() + return m, tea.Quit + case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + m.clearEditor() + return m, tea.Quit + } + } + + switch m.screen { + case managerListScreen: + return m.updateList(msg) + case managerActionScreen: + return m.updateActionMenu(msg) + case managerBackendScreen: + return m.updateBackendMenu(msg) + case managerEditorScreen: + return m.updateEditor(msg) + case managerSaveConfirmScreen: + return m.updateSaveConfirmation(msg) + case managerDeleteConfirmScreen: + return m.updateDeleteConfirmation(msg) + default: + m.screen = managerListScreen + return m, nil + } +} + +func (m *credentialManagerModel) View() tea.View { + var content string + switch m.screen { + case managerListScreen: + content = m.list.View() + case managerActionScreen: + content = m.viewActionMenu() + case managerBackendScreen: + content = m.viewBackendMenu() + case managerEditorScreen: + content = m.viewEditor() + case managerSaveConfirmScreen: + content = m.viewSaveConfirmation() + case managerDeleteConfirmScreen: + content = m.viewDeleteConfirmation() + default: + content = managerErrorStyle.Render("Invalid credential manager state") + } + + view := tea.NewView(content) + view.AltScreen = true + return view +} + +func (m *credentialManagerModel) updateList(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == "enter" && !m.list.SettingFilter() { + selected, ok := m.list.SelectedItem().(managerListItem) + if !ok { + return m, nil + } + + switch selected.kind { + case managerAddItem: + m.screen = managerBackendScreen + m.backendIndex = m.preferredBackendIndex() + m.menuIndex = 0 + return m, nil + case managerQuitItem: + return m, tea.Quit + case managerCredentialItem: + m.selectedRecord = selected.record + m.screen = managerActionScreen + m.menuIndex = 0 + return m, nil + } + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *credentialManagerModel) updateActionMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + keyName, ok := managerKeyName(msg) + if !ok { + return m, nil + } + + switch keyName { + case "up", "k", "shift+tab": + m.menuIndex = previousMenuIndex(m.menuIndex, 3) + case "down", "j", "tab": + m.menuIndex = nextMenuIndex(m.menuIndex, 3) + case "esc", "q": + m.screen = managerListScreen + return m, nil + case "enter": + switch m.menuIndex { + case 0: + value := normalizeCredentialForStorage(m.selectedRecord.credential) + if err := validateCredentialForStorage(value, false); err != nil { + m.editorError = safeErrorText(fmt.Errorf("cannot safely edit this legacy entry: %w", err)) + return m, nil + } + store := m.store(m.selectedRecord.backend) + if store == nil { + m.editorError = "Credential backend is unavailable." + return m, nil + } + return m, m.openEditor(value, false, store) + case 1: + m.screen = managerDeleteConfirmScreen + m.menuIndex = 1 // Default to Cancel; deletion must be deliberate. + m.editorError = "" + return m, nil + case 2: + m.screen = managerListScreen + return m, nil + } + } + return m, nil +} + +func (m *credentialManagerModel) updateBackendMenu(msg tea.Msg) (tea.Model, tea.Cmd) { + keyName, ok := managerKeyName(msg) + if !ok { + return m, nil + } + + switch keyName { + case "up", "k", "shift+tab": + m.backendIndex = previousMenuIndex(m.backendIndex, len(m.stores)) + case "down", "j", "tab": + m.backendIndex = nextMenuIndex(m.backendIndex, len(m.stores)) + case "esc", "q": + m.screen = managerListScreen + case "enter": + if m.backendIndex < 0 || m.backendIndex >= len(m.stores) { + return m, nil + } + return m, m.openEditor(credential{protocol: "https"}, true, m.stores[m.backendIndex]) + } + return m, nil +} + +func (m *credentialManagerModel) updateEditor(msg tea.Msg) (tea.Model, tea.Cmd) { + keyName, isKey := managerKeyName(msg) + if isKey { + switch keyName { + case "esc": + creating := m.editorCreating + m.clearEditor() + if creating { + m.screen = managerBackendScreen + } else { + m.screen = managerActionScreen + } + return m, nil + case "up", "shift+tab": + return m, m.focusEditorField(previousMenuIndex(m.editorField, managerFieldCount)) + case "down", "tab": + if err := m.validateEditorField(m.editorField); err != nil { + m.editorError = safeErrorText(err) + return m, nil + } + return m, m.focusEditorField(nextMenuIndex(m.editorField, managerFieldCount)) + case "enter": + if err := m.validateEditorField(m.editorField); err != nil { + m.editorError = safeErrorText(err) + return m, nil + } + if m.editorField < managerFieldCount-1 { + return m, m.focusEditorField(m.editorField + 1) + } + return m, m.prepareSave() + case "ctrl+s": + return m, m.prepareSave() + } + } + + if m.editorField < 0 || m.editorField >= len(m.editorInputs) { + return m, nil + } + oldValue := m.editorInputs[m.editorField].Value() + var cmd tea.Cmd + m.editorInputs[m.editorField], cmd = m.editorInputs[m.editorField].Update(msg) + if err := validateEditorInputSafety(m.editorField, m.editorInputs[m.editorField].Value()); err != nil { + m.editorInputs[m.editorField].SetValue(oldValue) + m.editorError = safeErrorText(err) + return m, cmd + } + if isKey { + m.editorError = "" + } + return m, cmd +} + +func (m *credentialManagerModel) updateSaveConfirmation(msg tea.Msg) (tea.Model, tea.Cmd) { + keyName, ok := managerKeyName(msg) + if !ok { + return m, nil + } + + switch keyName { + case "left", "up", "h", "k", "shift+tab": + m.menuIndex = previousMenuIndex(m.menuIndex, 2) + case "right", "down", "l", "j", "tab": + m.menuIndex = nextMenuIndex(m.menuIndex, 2) + case "esc", "n": + m.screen = managerEditorScreen + m.menuIndex = 0 + return m, m.focusEditorField(m.editorField) + case "y": + m.menuIndex = 0 + return m, m.saveEditor() + case "enter": + if m.menuIndex == 0 { + return m, m.saveEditor() + } + m.screen = managerEditorScreen + m.menuIndex = 0 + return m, m.focusEditorField(m.editorField) + } + return m, nil +} + +func (m *credentialManagerModel) updateDeleteConfirmation(msg tea.Msg) (tea.Model, tea.Cmd) { + keyName, ok := managerKeyName(msg) + if !ok { + return m, nil + } + + switch keyName { + case "left", "up", "h", "k", "shift+tab": + m.menuIndex = previousMenuIndex(m.menuIndex, 2) + case "right", "down", "l", "j", "tab": + m.menuIndex = nextMenuIndex(m.menuIndex, 2) + case "esc", "n": + m.screen = managerActionScreen + m.menuIndex = 1 + case "enter": + if m.menuIndex == 1 { + m.screen = managerActionScreen + m.menuIndex = 1 + return m, nil + } + store := m.store(m.selectedRecord.backend) + if store == nil { + m.editorError = "Credential backend is unavailable." + m.screen = managerActionScreen + return m, nil + } + if err := store.Delete(m.selectedRecord); err != nil { + if errors.Is(err, errCredentialChanged) { + m.pendingListStatus = "Credential storage changed concurrently; the list was reloaded." + if warning := m.reloadCredentialList(); warning != "" { + m.pendingListStatus += " Warning: " + warning + } + m.screen = managerListScreen + return m, m.showPendingListStatus() + } + m.editorError = safeErrorText(fmt.Errorf("delete credential from %s: %w", store.DisplayName(), err)) + m.screen = managerActionScreen + return m, nil + } + m.pendingListStatus = "Deleted " + displayCredential(m.selectedRecord.credential) + "." + if warning := m.reloadCredentialList(); warning != "" { + m.pendingListStatus += " Warning: " + warning + } + m.screen = managerListScreen + return m, m.showPendingListStatus() + } + return m, nil +} + +func (m *credentialManagerModel) openEditor(initial credential, creating bool, store managedCredentialStore) tea.Cmd { + m.clearEditor() + m.editorInputs = newCredentialEditorInputs(initial, creating) + m.editorField = 0 + m.editorCreating = creating + m.editorStore = store + m.editorError = "" + m.screen = managerEditorScreen + m.resizeEditorInputs() + return m.editorInputs[0].Focus() +} + +func newCredentialEditorInputs(initial credential, creating bool) []textinput.Model { + values := []string{initial.protocol, initial.host, initial.path, initial.username, ""} + placeholders := []string{ + "https", + "github.com or git.example.com:8443", + "organization/repository.git (optional)", + "Git credential username", + "Password or personal access token", + } + + inputs := make([]textinput.Model, managerFieldCount) + for i := range inputs { + inputs[i] = textinput.New() + inputs[i].Prompt = "> " + inputs[i].Placeholder = placeholders[i] + inputs[i].CharLimit = maxCredentialFieldBytes + inputs[i].SetValue(values[i]) + } + inputs[4].EchoMode = textinput.EchoPassword + if !creating { + inputs[4].Placeholder = "Leave blank to keep the current secret" + } + return inputs +} + +func (m *credentialManagerModel) focusEditorField(index int) tea.Cmd { + if len(m.editorInputs) != managerFieldCount { + return nil + } + if m.editorField >= 0 && m.editorField < len(m.editorInputs) { + m.editorInputs[m.editorField].Blur() + } + m.editorField = index + m.editorError = "" + return m.editorInputs[m.editorField].Focus() +} + +func (m *credentialManagerModel) prepareSave() tea.Cmd { + value, err := m.validatedEditorCredential() + if err != nil { + m.editorError = safeErrorText(err) + return nil + } + for i := range m.editorInputs { + m.editorInputs[i].Blur() + } + // Keep the validated, normalized non-secret fields visible in confirmation. + m.editorInputs[0].SetValue(value.protocol) + m.editorInputs[1].SetValue(value.host) + m.editorInputs[2].SetValue(value.path) + m.editorInputs[3].SetValue(value.username) + m.screen = managerSaveConfirmScreen + m.menuIndex = 0 + m.editorError = "" + return nil +} + +func (m *credentialManagerModel) saveEditor() tea.Cmd { + value, err := m.validatedEditorCredential() + if err != nil { + m.editorError = safeErrorText(err) + m.screen = managerEditorScreen + return m.focusEditorField(m.editorField) + } + if m.editorStore == nil { + m.editorError = "Credential backend is unavailable." + m.screen = managerEditorScreen + return m.focusEditorField(m.editorField) + } + + display := displayCredential(value) + storeName := safeTerminalText(m.editorStore.DisplayName()) + storeBackend := m.editorStore.Backend() + if m.editorCreating { + err = m.editorStore.Add(value) + } else { + err = m.editorStore.Update(m.selectedRecord, value) + } + value.password = "" + if err != nil { + if !m.editorCreating && errors.Is(err, errCredentialChanged) { + m.clearEditor() + m.pendingListStatus = "Credential storage changed concurrently; the list was reloaded." + if warning := m.reloadCredentialList(); warning != "" { + m.pendingListStatus += " Warning: " + warning + } + m.screen = managerListScreen + return m.showPendingListStatus() + } + action := "update" + if m.editorCreating { + action = "add" + } + m.editorError = safeErrorText(fmt.Errorf("%s credential in %s: %w", action, storeName, err)) + m.screen = managerEditorScreen + return m.focusEditorField(m.editorField) + } + + action := "Updated" + if m.editorCreating { + action = "Added" + } + m.clearEditor() + m.pendingListStatus = fmt.Sprintf("%s %s in %s.", action, display, storeName) + if storeBackend == keyringBackend { + m.pendingListStatus += " Configure the helper with --backend keyring or --backend auto for Git lookups." + } + if warning := m.reloadCredentialList(); warning != "" { + m.pendingListStatus += " Warning: " + warning + } + m.screen = managerListScreen + return m.showPendingListStatus() +} + +func (m *credentialManagerModel) editorCredential() credential { + if len(m.editorInputs) != managerFieldCount { + return credential{} + } + return credential{ + protocol: m.editorInputs[0].Value(), + host: m.editorInputs[1].Value(), + path: m.editorInputs[2].Value(), + username: m.editorInputs[3].Value(), + password: m.editorInputs[4].Value(), + } +} + +func (m *credentialManagerModel) validatedEditorCredential() (credential, error) { + value := normalizeCredentialForStorage(m.editorCredential()) + if err := validateCredentialForStorage(value, m.editorCreating); err != nil { + return credential{}, err + } + return value, nil +} + +func (m *credentialManagerModel) validateEditorField(index int) error { + value := m.editorCredential() + switch index { + case 0: + return validateCredentialProtocol(value.protocol) + case 1: + return validateCredentialHost(value.protocol, value.host) + case 2: + return validateCredentialPath(value.path) + case 3: + return validateCredentialUsername(value.username) + case 4: + return validateCredentialPassword(value.password, m.editorCreating) + default: + return errors.New("invalid editor field") + } +} + +func (m *credentialManagerModel) clearEditor() { + for i := range m.editorInputs { + m.editorInputs[i].SetValue("") + m.editorInputs[i].Blur() + } + m.editorInputs = nil + m.editorField = 0 + m.editorCreating = false + m.editorStore = nil + m.editorError = "" +} + +func (m *credentialManagerModel) reloadCredentialList() string { + items := make([]list.Item, 0) + var listErrors []error + for _, store := range m.stores { + records, err := store.List() + if err != nil { + listErrors = append(listErrors, fmt.Errorf("list %s credentials: %w", store.DisplayName(), err)) + } + for _, record := range records { + record.credential.password = "" + items = append(items, managerListItem{ + kind: managerCredentialItem, + record: record, + title: displayCredential(record.credential), + description: safeTerminalText(store.DisplayName()), + }) + } + } + + items = append(items, + managerListItem{ + kind: managerAddItem, + title: "Add credential…", + description: "Create a validated credential in the system keyring or credential file", + }, + managerListItem{ + kind: managerQuitItem, + title: "Quit", + description: "Exit without changing any other credentials", + }, + ) + m.list.ResetFilter() + m.list.SetItems(items) + m.list.Select(0) + + if len(listErrors) == 0 { + return "" + } + return safeErrorText(errors.Join(listErrors...)) +} + +func (m *credentialManagerModel) showPendingListStatus() tea.Cmd { + if m.pendingListStatus == "" { + return nil + } + message := safeTerminalText(m.pendingListStatus) + m.pendingListStatus = "" + return m.list.NewStatusMessage(message) +} + +func (m *credentialManagerModel) resizeEditorInputs() { + inputWidth := min(max(m.width-8, 8), 100) + for i := range m.editorInputs { + m.editorInputs[i].SetWidth(inputWidth) + } +} + +func (m *credentialManagerModel) preferredBackendIndex() int { + for i, store := range m.stores { + if store.Backend() == keyringBackend { + return i + } + } + return 0 +} + +func (m *credentialManagerModel) store(backend credentialBackend) managedCredentialStore { + for _, store := range m.stores { + if store.Backend() == backend { + return store + } + } + return nil +} + +func (m *credentialManagerModel) viewActionMenu() string { + store := m.store(m.selectedRecord.backend) + storeName := string(m.selectedRecord.backend) + if store != nil { + storeName = store.DisplayName() + } + content := renderManagerMenu( + "Manage credential", + displayCredential(m.selectedRecord.credential)+"\n"+safeTerminalText(storeName), + []string{"Edit", "Delete", "Back"}, + m.menuIndex, + ) + return m.withManagerError(content, "↑/↓ move • enter select • esc back • ctrl+c quit") +} + +func (m *credentialManagerModel) viewBackendMenu() string { + options := make([]string, 0, len(m.stores)) + for _, store := range m.stores { + label := safeTerminalText(store.DisplayName()) + if store.Backend() == keyringBackend { + label += " (recommended)" + } else if store.Backend() == fileBackend { + label += " (plaintext compatibility)" + } + options = append(options, label) + } + content := renderManagerMenu( + "Add credential", + "Choose where the password or token will be stored.\n"+ + "Keyring entries require --backend keyring or --backend auto for Git lookups.", + options, + m.backendIndex, + ) + return managerFrame(content + "\n\n" + managerHelpStyle.Render("↑/↓ move • enter select • esc back • ctrl+c quit")) +} + +func (m *credentialManagerModel) viewEditor() string { + title := "Edit credential" + secretHelp := "Leave blank to retain the current password or token." + if m.editorCreating { + title = "Add credential" + secretHelp = "Required; the value is masked and never shown in lists or confirmation." + } + storeName := "" + if m.editorStore != nil { + storeName = "\nStorage: " + safeTerminalText(m.editorStore.DisplayName()) + } + + labels := []string{"Protocol", "Host", "Path scope", "Username", "Password or token"} + descriptions := []string{ + "URI scheme, usually https.", + "Hostname and optional port only; do not include a scheme or path.", + "Optional; for example organization/repository.git.", + "The HTTP credential username.", + secretHelp, + } + var fields strings.Builder + firstField := 0 + lastField := len(m.editorInputs) + compact := m.height < 22 + if compact && len(m.editorInputs) > 0 { + firstField = m.editorField + lastField = m.editorField + 1 + } + for i := firstField; i < lastField; i++ { + label := labels[i] + if compact { + label = fmt.Sprintf("[%d/%d] %s", i+1, managerFieldCount, label) + } + if i == m.editorField { + label = managerSelectedStyle.Render(label) + } else { + label = managerSubtitleStyle.Render(label) + } + fmt.Fprintf(&fields, "%s\n%s\n%s", label, m.editorInputs[i].View(), managerSubtitleStyle.Render(descriptions[i])) + if i < lastField-1 { + fields.WriteString("\n\n") + } + } + + content := managerTitleStyle.Render(title) + + "\n" + managerSubtitleStyle.Render("Structured fields are validated and encoded automatically."+storeName) + + "\n\n" + fields.String() + return m.withManagerError(content, "tab/enter next • shift+tab/up previous • ctrl+s review • esc cancel") +} + +func (m *credentialManagerModel) viewSaveConfirmation() string { + value, err := m.validatedEditorCredential() + description := "Credential fields are no longer valid. Return to the editor." + if err == nil { + description = displayCredential(value) + if value.path == "" { + description += "\nNo path scope: Git may use this credential for any repository on the host." + } else { + description += "\nPath-scoped credentials require Git credential.useHttpPath=true." + } + } + content := renderManagerMenu( + "Save this credential?", + description+"\nThe password or token is intentionally not displayed.", + []string{"Save", "Cancel"}, + m.menuIndex, + ) + return managerFrame(content + "\n\n" + managerHelpStyle.Render("←/→ choose • enter confirm • esc cancel • ctrl+c quit")) +} + +func (m *credentialManagerModel) viewDeleteConfirmation() string { + content := renderManagerMenu( + "Delete this credential?", + displayCredential(m.selectedRecord.credential)+"\nThe secret cannot be recovered by this application.", + []string{"Delete", "Cancel"}, + m.menuIndex, + ) + return managerFrame(content + "\n\n" + managerWarningStyle.Render("Cancel is selected by default.") + + "\n" + managerHelpStyle.Render("←/→ choose • enter confirm • esc cancel • ctrl+c quit")) +} + +func (m *credentialManagerModel) withManagerError(content, help string) string { + if m.editorError != "" { + content += "\n\n" + managerErrorStyle.Render("Error: "+safeTerminalText(m.editorError)) + } + return managerFrame(content + "\n\n" + managerHelpStyle.Render(help)) +} + +func managerFrame(content string) string { + return lipgloss.NewStyle().Padding(1, 2).Render(content) +} + +func renderManagerMenu(title, description string, options []string, selected int) string { + var content strings.Builder + content.WriteString(managerTitleStyle.Render(title)) + if description != "" { + content.WriteString("\n") + content.WriteString(managerSubtitleStyle.Render(description)) + } + content.WriteString("\n\n") + for i, option := range options { + prefix := " " + line := safeTerminalText(option) + if i == selected { + prefix = "> " + line = managerSelectedStyle.Render(line) + } + content.WriteString(prefix) + content.WriteString(line) + if i < len(options)-1 { + content.WriteByte('\n') + } + } + return content.String() +} + +func managerKeyName(msg tea.Msg) (string, bool) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return "", false + } + return keyMsg.String(), true +} + +func validateEditorInputSafety(index int, value string) error { + names := []string{"protocol", "host", "path", "username", "password or token"} + if index < 0 || index >= len(names) { + return errors.New("invalid editor field") + } + return validateCredentialText(names[index], value, true) +} + +func previousMenuIndex(index, count int) int { + if count <= 0 { + return 0 + } + return (index - 1 + count) % count +} + +func nextMenuIndex(index, count int) int { + if count <= 0 { + return 0 + } + return (index + 1) % count +} + +func safeErrorText(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(safeTerminalText(err.Error())) +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..8ae63b7 --- /dev/null +++ b/tui_test.go @@ -0,0 +1,361 @@ +package main + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +type memoryCredentialStore struct { + backend credentialBackend + name string + records []credentialRecord + + added []credential + updated []credential + deleted []credentialRecord + + listErr error + addErr error + updateErr error + deleteErr error +} + +func (s *memoryCredentialStore) Backend() credentialBackend { return s.backend } +func (s *memoryCredentialStore) DisplayName() string { return s.name } + +func (s *memoryCredentialStore) List() ([]credentialRecord, error) { + if s.listErr != nil { + return nil, s.listErr + } + records := append([]credentialRecord(nil), s.records...) + for i := range records { + records[i].credential.password = "" + } + return records, nil +} + +func (s *memoryCredentialStore) Add(value credential) error { + if s.addErr != nil { + return s.addErr + } + s.added = append(s.added, value) + listed := value + listed.password = "" + s.records = append(s.records, credentialRecord{ + id: "added", + backend: s.backend, + credential: listed, + }) + return nil +} + +func (s *memoryCredentialStore) Update(_ credentialRecord, value credential) error { + if s.updateErr != nil { + return s.updateErr + } + s.updated = append(s.updated, value) + return nil +} + +func (s *memoryCredentialStore) Delete(record credentialRecord) error { + if s.deleteErr != nil { + return s.deleteErr + } + s.deleted = append(s.deleted, record) + for i, existing := range s.records { + if existing.id == record.id { + s.records = append(s.records[:i], s.records[i+1:]...) + break + } + } + return nil +} + +func (s *memoryCredentialStore) Lookup(*credential) (*credential, error) { + return nil, nil +} + +func TestCredentialManagerAddUsesStructuredValidatedFields(t *testing.T) { + store := &memoryCredentialStore{backend: keyringBackend, name: "Test keyring"} + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.openEditor(credential{protocol: "https"}, true, store) + + want := credential{ + protocol: "https", + host: "gitlab.example.com:8443", + path: "group/a+b #?/repository.git", + username: "user+name@example.com", + password: "tok:en@/%+?", + } + setManagerEditorValues(model, want) + + model.prepareSave() + if model.screen != managerSaveConfirmScreen { + t.Fatalf("screen = %v, want save confirmation; error = %q", model.screen, model.editorError) + } + if strings.Contains(model.View().Content, want.password) { + t.Fatal("save confirmation exposed the credential secret") + } + if !strings.Contains(model.View().Content, "credential.useHttpPath=true") { + t.Fatal("path-scoped confirmation omitted the required Git configuration guidance") + } + + model.saveEditor() + if len(store.added) != 1 { + t.Fatalf("added credentials = %d, want 1", len(store.added)) + } + if store.added[0] != want { + t.Fatalf("added credential = %+v, want %+v", store.added[0], want) + } + if model.screen != managerListScreen { + t.Fatalf("screen = %v, want credential list", model.screen) + } + if strings.Contains(model.View().Content, want.password) { + t.Fatal("credential list exposed the credential secret") + } +} + +func TestCredentialManagerEditorRejectsInvalidFields(t *testing.T) { + store := &memoryCredentialStore{backend: fileBackend, name: "Credential file"} + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.openEditor(credential{protocol: "https"}, true, store) + setManagerEditorValues(model, credential{ + protocol: "https", + host: "https://example.com/repository", + username: "user", + password: "secret", + }) + + model.prepareSave() + if model.screen != managerEditorScreen { + t.Fatalf("screen = %v, want editor", model.screen) + } + if !strings.Contains(model.editorError, "host") { + t.Fatalf("editor error = %q, want host validation error", model.editorError) + } + if len(store.added) != 0 { + t.Fatal("invalid credential was added") + } + + model.editorInputs[1].SetValue("example.com") + model.editorInputs[4].SetValue("") + model.prepareSave() + if !strings.Contains(model.editorError, "password or token") { + t.Fatalf("editor error = %q, want required secret error", model.editorError) + } +} + +func TestCredentialManagerRejectsControlCharactersBeforeRendering(t *testing.T) { + store := &memoryCredentialStore{backend: fileBackend, name: "Credential file"} + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.openEditor(credential{protocol: "https"}, true, store) + model.editorField = 1 + model.editorInputs[0].Blur() + model.editorInputs[1].Focus() + + _, _ = model.updateEditor(tea.PasteMsg{Content: "example.com\x1b[31m"}) + if got := model.editorInputs[1].Value(); strings.ContainsRune(got, '\x1b') { + t.Fatalf("unsafe input retained a terminal escape: %q", got) + } + if err := validateEditorInputSafety(1, "example.com\x1b[31m"); err == nil || + !strings.Contains(err.Error(), "control characters") { + t.Fatalf("safety validation error = %v, want control-character error", err) + } + // Lip Gloss itself emits ANSI styling, so check the complete malicious + // sequence rather than rejecting all escape bytes in the rendered view. + if strings.Contains(model.View().Content, "example.com\x1b[31m") { + t.Fatal("unsafe pasted terminal sequence was rendered") + } +} + +func TestCredentialManagerEditLeavesSecretBlankForBackendPreservation(t *testing.T) { + record := credentialRecord{ + id: "existing", + backend: fileBackend, + credential: credential{ + protocol: "https", + host: "example.com", + path: "old", + username: "user", + }, + } + store := &memoryCredentialStore{ + backend: fileBackend, + name: "Credential file", + records: []credentialRecord{record}, + } + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.selectedRecord = record + model.openEditor(record.credential, false, store) + model.editorInputs[2].SetValue("new/repository.git") + + model.prepareSave() + model.saveEditor() + if len(store.updated) != 1 { + t.Fatalf("updated credentials = %d, want 1", len(store.updated)) + } + if store.updated[0].password != "" { + t.Fatal("editor synthesized or exposed an existing secret") + } + if store.updated[0].path != "new/repository.git" { + t.Fatalf("updated path = %q", store.updated[0].path) + } +} + +func TestCredentialManagerDeleteDefaultsToCancel(t *testing.T) { + record := credentialRecord{ + id: "existing", + backend: keyringBackend, + credential: credential{ + protocol: "https", + host: "example.com", + username: "user", + }, + } + store := &memoryCredentialStore{ + backend: keyringBackend, + name: "Test keyring", + records: []credentialRecord{record}, + } + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.selectedRecord = record + model.screen = managerActionScreen + model.menuIndex = 1 + + _, _ = model.updateActionMenu(testManagerEnterKey()) + if model.screen != managerDeleteConfirmScreen || model.menuIndex != 1 { + t.Fatalf("delete confirmation state = (%v, %d), want Cancel selected", model.screen, model.menuIndex) + } + _, _ = model.updateDeleteConfirmation(testManagerEnterKey()) + if len(store.deleted) != 0 { + t.Fatal("default confirmation deleted the credential") + } + if model.screen != managerActionScreen { + t.Fatalf("screen = %v, want action menu", model.screen) + } +} + +func TestCredentialManagerReloadsAfterConcurrentEdit(t *testing.T) { + record := credentialRecord{ + id: "stale", + backend: fileBackend, + credential: credential{ + protocol: "https", + host: "example.com", + username: "user", + }, + } + store := &memoryCredentialStore{ + backend: fileBackend, + name: "Credential file", + records: []credentialRecord{record}, + updateErr: errCredentialChanged, + } + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.selectedRecord = record + model.openEditor(record.credential, false, store) + model.editorInputs[4].SetValue("replacement-secret") + model.prepareSave() + model.saveEditor() + + if model.screen != managerListScreen { + t.Fatalf("screen = %v, want reloaded credential list", model.screen) + } + if len(model.editorInputs) != 0 { + t.Fatal("editor and replacement secret remained in memory after reload") + } +} + +func TestCredentialManagerKeepsWorkingWhenOneBackendCannotList(t *testing.T) { + unavailable := &memoryCredentialStore{ + backend: keyringBackend, + name: "Test keyring", + listErr: errors.New("service unavailable\x1b[31m"), + } + fileStore := &memoryCredentialStore{backend: fileBackend, name: "Credential file"} + model, err := newCredentialManagerModel([]managedCredentialStore{unavailable, fileStore}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(model.pendingListStatus, "service unavailable") { + t.Fatalf("status = %q, want backend warning", model.pendingListStatus) + } + if strings.ContainsRune(model.pendingListStatus, '\x1b') { + t.Fatal("backend error retained a terminal escape") + } + if got := len(model.list.Items()); got != 2 { + t.Fatalf("list items = %d, want Add and Quit", got) + } +} + +func TestCredentialManagerListUsesConventionalQuitKey(t *testing.T) { + store := &memoryCredentialStore{backend: fileBackend, name: "Credential file"} + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + _, cmd := model.updateList(tea.KeyPressMsg(tea.Key{Code: 'q', Text: "q"})) + if cmd == nil { + t.Fatal("q did not produce a quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("q command returned %T, want tea.QuitMsg", cmd()) + } +} + +func TestCredentialManagerEditorUsesCompactLayoutInSmallTerminal(t *testing.T) { + store := &memoryCredentialStore{backend: fileBackend, name: "Credential file"} + model, err := newCredentialManagerModel([]managedCredentialStore{store}) + if err != nil { + t.Fatal(err) + } + model.openEditor(credential{protocol: "https"}, true, store) + setManagerEditorValues(model, credential{ + protocol: "https", + host: "example.com", + username: "user", + password: "small-terminal-secret", + }) + _, _ = model.Update(tea.WindowSizeMsg{Width: 20, Height: 10}) + + view := model.View().Content + if !strings.Contains(view, "[1/5] Protocol") { + t.Fatalf("compact editor view did not identify the active field: %q", view) + } + if strings.Contains(view, "small-terminal-secret") { + t.Fatal("compact editor exposed the credential secret") + } +} + +func setManagerEditorValues(model *credentialManagerModel, value credential) { + model.editorInputs[0].SetValue(value.protocol) + model.editorInputs[1].SetValue(value.host) + model.editorInputs[2].SetValue(value.path) + model.editorInputs[3].SetValue(value.username) + model.editorInputs[4].SetValue(value.password) +} + +func testManagerEnterKey() tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}) +}