mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
refactor(credstore): embed credential store and drop sdk-go dependency
- Embed the minimal credstore subset used by tea (SecureStore, EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so external SDK renames can no longer break the build - Keep the on-disk format fully compatible: AES-256-GCM values with the v1: prefix, credentials.json / credentials.json.enc paths, and the Token JSON field names are unchanged, verified by a ciphertext fixture generated with sdk-go v1.1.0 - Store the keyring master key under a tea-owned account name - Reuse the existing kernel-level filelock module instead of the upstream lockfile protocol, removing a stale-lock race - Cover roundtrip, keyring-unavailable fallback, and fixture decryption with tests using a mocked keyring - Remove github.com/go-signet/sdk-go and promote github.com/zalando/go-keyring to a direct dependency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dev/tea/modules/filelock"
|
||||
)
|
||||
|
||||
// storageMap manages encoded values for multiple clients.
|
||||
type storageMap struct {
|
||||
Data map[string]string `json:"data"` // clientID -> encoded value
|
||||
}
|
||||
|
||||
// FileStore stores values in a JSON file with file locking and atomic writes.
|
||||
type FileStore[T any] struct {
|
||||
filePath string
|
||||
codec Codec[T]
|
||||
}
|
||||
|
||||
// NewFileStore creates a new FileStore with the given codec.
|
||||
// Panics if codec is nil.
|
||||
func NewFileStore[T any](filePath string, codec Codec[T]) *FileStore[T] {
|
||||
if codec == nil {
|
||||
panic("credstore: NewFileStore called with nil codec")
|
||||
}
|
||||
return &FileStore[T]{filePath: filePath, codec: codec}
|
||||
}
|
||||
|
||||
// readStorageMap reads and unmarshals the storage map from the file.
|
||||
// Returns an empty initialized map if the file does not exist.
|
||||
func (f *FileStore[T]) readStorageMap() (storageMap, error) {
|
||||
var m storageMap
|
||||
data, err := os.ReadFile(f.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
m.Data = make(map[string]string)
|
||||
return m, nil
|
||||
}
|
||||
return m, fmt.Errorf("failed to read file %q: %w", f.filePath, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return m, fmt.Errorf("failed to parse file %q: %w", f.filePath, err)
|
||||
}
|
||||
if m.Data == nil {
|
||||
m.Data = make(map[string]string)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ensureDir creates the parent directory of the store file if it does not exist.
|
||||
func (f *FileStore[T]) ensureDir() error {
|
||||
if err := os.MkdirAll(filepath.Dir(f.filePath), 0o700); err != nil {
|
||||
return fmt.Errorf("failed to create store directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeStorageMap marshals and atomically writes the storage map to the file.
|
||||
func (f *FileStore[T]) writeStorageMap(m storageMap) error {
|
||||
data, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tempFile := f.filePath + ".tmp"
|
||||
if err := os.WriteFile(tempFile, data, 0o600); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
// WriteFile only applies the mode when creating the file; enforce it in
|
||||
// case a stale temp file with looser permissions was left behind.
|
||||
if err := os.Chmod(tempFile, 0o600); err != nil {
|
||||
_ = os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to set temp file permissions: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tempFile, f.filePath); err != nil {
|
||||
_ = os.Remove(tempFile)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// withFileLock acquires an exclusive cross-process lock on filePath+".lock",
|
||||
// runs fn, and releases the lock. The kernel-level lock (flock/LockFileEx via
|
||||
// modules/filelock) is released automatically if the process dies, so no
|
||||
// stale-lock heuristics are needed. The .lock file itself remains on disk.
|
||||
func (f *FileStore[T]) withFileLock(fn func() error) error {
|
||||
return filelock.New(f.filePath+".lock", filelock.DefaultTimeout).WithLock(fn)
|
||||
}
|
||||
|
||||
// Load loads data from the file for the given client ID.
|
||||
// No file lock is needed: Save uses atomic rename, so reads always see a
|
||||
// consistent snapshot on POSIX systems.
|
||||
func (f *FileStore[T]) Load(clientID string) (T, error) {
|
||||
var zero T
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
encoded, ok := m.Data[clientID]
|
||||
if !ok {
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
|
||||
decoded, err := f.codec.Decode(encoded)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("failed to decode value from %q: %w", f.filePath, err)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Save saves data to the file for the given client ID.
|
||||
// Uses file locking to prevent race conditions.
|
||||
// Automatically creates parent directories if they do not exist.
|
||||
func (f *FileStore[T]) Save(clientID string, data T) error {
|
||||
if clientID == "" {
|
||||
return ErrEmptyClientID
|
||||
}
|
||||
|
||||
encoded, err := f.codec.Encode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode value for storage: %w", err)
|
||||
}
|
||||
|
||||
if err := f.ensureDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return f.withFileLock(func() error {
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.Data[clientID] = encoded
|
||||
|
||||
return f.writeStorageMap(m)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes data for the given client ID from the file.
|
||||
func (f *FileStore[T]) Delete(clientID string) error {
|
||||
return f.withFileLock(func() error {
|
||||
m, err := f.readStorageMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := m.Data[clientID]; !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(m.Data, clientID)
|
||||
|
||||
return f.writeStorageMap(m)
|
||||
})
|
||||
}
|
||||
|
||||
// String returns a description of this store.
|
||||
func (f *FileStore[T]) String() string {
|
||||
return "file: " + f.filePath
|
||||
}
|
||||
Reference in New Issue
Block a user