feat(auth): add SessionStore interface with SQLite persistence
Extract in-memory session map into SessionStore interface with two implementations: MemorySessionStore (default, backward-compatible) and SQLiteSessionStore (persistent via go-store). Add WithSessionStore option, background cleanup goroutine, and comprehensive tests including persistence verification and concurrency safety. Phase 1: Session Persistence — complete. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
9331fc6eac
commit
1aeabfd32b
9 changed files with 892 additions and 53 deletions
|
|
@ -27,6 +27,7 @@ go vet ./... # Static analysis
|
|||
| Module | Local Path | Notes |
|
||||
|--------|-----------|-------|
|
||||
| `forge.lthn.ai/core/go` | `../go` | Framework (core.E, core.Crypt, io.Medium) |
|
||||
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
|
||||
|
||||
**Do NOT change the replace directive path.** Use go.work for local resolution if needed.
|
||||
|
||||
|
|
@ -48,7 +49,11 @@ go vet ./... # Static analysis
|
|||
2. Client signs offline
|
||||
3. `ReadResponseFile(userID, path)` → verify, issue session
|
||||
|
||||
**Session management**: In-memory `sync.RWMutex`-protected map. 32-byte hex tokens, 24h TTL. `ValidateSession`, `RefreshSession`, `RevokeSession`.
|
||||
**Session management**: Abstracted behind `SessionStore` interface. 32-byte hex tokens, 24h TTL. `ValidateSession`, `RefreshSession`, `RevokeSession`. Two implementations:
|
||||
- `MemorySessionStore` — in-memory `sync.RWMutex`-protected map (default, sessions lost on restart)
|
||||
- `SQLiteSessionStore` — persistent via go-store (SQLite KV), mutex-serialised for single-writer safety
|
||||
|
||||
Configure via `WithSessionStore(store)` option. Background cleanup via `StartCleanup(ctx, interval)`.
|
||||
|
||||
**Protected users**: `"server"` cannot be deleted.
|
||||
|
||||
|
|
@ -118,7 +123,7 @@ Service wrapper implementing `core.Crypt` interface. RSA-4096, SHA-256, AES-256.
|
|||
## Security Considerations
|
||||
|
||||
1. **LTHN hash is NOT for passwords** — deterministic, no random salt. Use `HashPassword()` (Argon2id) instead.
|
||||
2. **Sessions are in-memory only** — lost on restart. No Redis/DB backend yet.
|
||||
2. **Sessions default to in-memory** — use `WithSessionStore(NewSQLiteSessionStore(path))` for persistence across restarts.
|
||||
3. **PGP output is armored** — ~33% Base64 overhead. Consider compression for large payloads.
|
||||
4. **Policy engine returns decisions but doesn't enforce approval workflow** — higher-level layer needed.
|
||||
5. **Challenge nonces are 32 bytes** — 256-bit, cryptographically random.
|
||||
|
|
|
|||
8
TODO.md
8
TODO.md
|
|
@ -15,10 +15,10 @@ Dispatched from core/go orchestration. Pick up tasks in order.
|
|||
|
||||
## Phase 1: Session Persistence
|
||||
|
||||
- [ ] **Session storage interface** — Extract in-memory session map into `SessionStore` interface with `Get`, `Set`, `Delete`, `Cleanup` methods.
|
||||
- [ ] **SQLite session store** — Implement `SessionStore` backed by go-store (SQLite KV). Migrate session tokens + expiry to persistent storage.
|
||||
- [ ] **Background cleanup** — Goroutine to purge expired sessions periodically. Configurable interval.
|
||||
- [ ] **Session migration** — Backward-compatible: in-memory as default, optional persistent store via config.
|
||||
- [x] **Session storage interface** — Extracted in-memory session map into `SessionStore` interface with `Get`, `Set`, `Delete`, `DeleteByUser`, `Cleanup` methods. `MemorySessionStore` wraps the original map+mutex pattern. `ErrSessionNotFound` sentinel error.
|
||||
- [x] **SQLite session store** — `SQLiteSessionStore` backed by go-store (SQLite KV). Sessions stored as JSON in `"sessions"` group. Mutex-serialised for SQLite single-writer safety.
|
||||
- [x] **Background cleanup** — `StartCleanup(ctx, interval)` goroutine purges expired sessions periodically. Stops on context cancellation.
|
||||
- [x] **Session migration** — Backward-compatible: `MemorySessionStore` is default, `WithSessionStore(store)` option for persistent store. All existing tests updated and passing.
|
||||
|
||||
## Phase 2: Key Management
|
||||
|
||||
|
|
|
|||
93
auth/auth.go
93
auth/auth.go
|
|
@ -25,6 +25,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
|
|
@ -94,23 +95,34 @@ func WithSessionTTL(d time.Duration) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithSessionStore sets the SessionStore implementation.
|
||||
// If not provided, an in-memory store is used (sessions lost on restart).
|
||||
func WithSessionStore(s SessionStore) Option {
|
||||
return func(a *Authenticator) {
|
||||
a.store = s
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticator manages PGP-based challenge-response authentication.
|
||||
// All user data and keys are persisted through an io.Medium, which may
|
||||
// be backed by disk, memory (MockMedium), or any other storage backend.
|
||||
// Sessions are persisted via a SessionStore (in-memory by default,
|
||||
// optionally SQLite-backed for crash recovery).
|
||||
type Authenticator struct {
|
||||
medium io.Medium
|
||||
sessions map[string]*Session
|
||||
store SessionStore
|
||||
challenges map[string]*Challenge // userID -> pending challenge
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex // protects challenges map only
|
||||
challengeTTL time.Duration
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
// New creates an Authenticator that persists user data via the given Medium.
|
||||
// By default, sessions are stored in memory. Use WithSessionStore to provide
|
||||
// a persistent implementation (e.g. SQLiteSessionStore).
|
||||
func New(m io.Medium, opts ...Option) *Authenticator {
|
||||
a := &Authenticator{
|
||||
medium: m,
|
||||
sessions: make(map[string]*Session),
|
||||
challenges: make(map[string]*Challenge),
|
||||
challengeTTL: DefaultChallengeTTL,
|
||||
sessionTTL: DefaultSessionTTL,
|
||||
|
|
@ -118,6 +130,10 @@ func New(m io.Medium, opts ...Option) *Authenticator {
|
|||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
// Default to in-memory store if none provided via WithSessionStore
|
||||
if a.store == nil {
|
||||
a.store = NewMemorySessionStore()
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
|
|
@ -278,18 +294,13 @@ func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Se
|
|||
func (a *Authenticator) ValidateSession(token string) (*Session, error) {
|
||||
const op = "auth.ValidateSession"
|
||||
|
||||
a.mu.RLock()
|
||||
session, exists := a.sessions[token]
|
||||
a.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
session, err := a.store.Get(token)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.mu.Unlock()
|
||||
_ = a.store.Delete(token)
|
||||
return nil, coreerr.E(op, "session expired", nil)
|
||||
}
|
||||
|
||||
|
|
@ -300,20 +311,20 @@ func (a *Authenticator) ValidateSession(token string) (*Session, error) {
|
|||
func (a *Authenticator) RefreshSession(token string) (*Session, error) {
|
||||
const op = "auth.RefreshSession"
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
session, exists := a.sessions[token]
|
||||
if !exists {
|
||||
session, err := a.store.Get(token)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
delete(a.sessions, token)
|
||||
_ = a.store.Delete(token)
|
||||
return nil, coreerr.E(op, "session expired", nil)
|
||||
}
|
||||
|
||||
session.ExpiresAt = time.Now().Add(a.sessionTTL)
|
||||
if err := a.store.Set(session); err != nil {
|
||||
return nil, coreerr.E(op, "failed to update session", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
|
@ -321,14 +332,9 @@ func (a *Authenticator) RefreshSession(token string) (*Session, error) {
|
|||
func (a *Authenticator) RevokeSession(token string) error {
|
||||
const op = "auth.RevokeSession"
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if _, exists := a.sessions[token]; !exists {
|
||||
if err := a.store.Delete(token); err != nil {
|
||||
return coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
delete(a.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -360,13 +366,7 @@ func (a *Authenticator) DeleteUser(userID string) error {
|
|||
}
|
||||
|
||||
// Revoke any active sessions for this user
|
||||
a.mu.Lock()
|
||||
for token, session := range a.sessions {
|
||||
if session.UserID == userID {
|
||||
delete(a.sessions, token)
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
_ = a.store.DeleteByUser(userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -434,7 +434,7 @@ func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error)
|
|||
}
|
||||
|
||||
// createSession generates a cryptographically random session token and
|
||||
// stores the session in the in-memory session map.
|
||||
// stores the session via the SessionStore.
|
||||
func (a *Authenticator) createSession(userID string) (*Session, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
|
|
@ -447,9 +447,34 @@ func (a *Authenticator) createSession(userID string) (*Session, error) {
|
|||
ExpiresAt: time.Now().Add(a.sessionTTL),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.sessions[session.Token] = session
|
||||
a.mu.Unlock()
|
||||
if err := a.store.Set(session); err != nil {
|
||||
return nil, fmt.Errorf("auth: failed to persist session: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// StartCleanup runs a background goroutine that periodically removes expired
|
||||
// sessions from the store. It stops when the context is cancelled.
|
||||
func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
count, err := a.store.Cleanup()
|
||||
if err != nil {
|
||||
fmt.Printf("auth: session cleanup error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
fmt.Printf("auth: cleaned up %d expired session(s)\n", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,7 +315,7 @@ func TestDeleteUser_Good(t *testing.T) {
|
|||
userID := lthn.Hash("larry")
|
||||
|
||||
// Also create a session that should be cleaned up
|
||||
_, err = a.Login(userID, "pass")
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = a.DeleteUser(userID)
|
||||
|
|
@ -328,16 +328,10 @@ func TestDeleteUser_Good(t *testing.T) {
|
|||
assert.False(t, m.IsFile(userPath(userID, ".json")))
|
||||
assert.False(t, m.IsFile(userPath(userID, ".lthn")))
|
||||
|
||||
// Session should be gone
|
||||
a.mu.RLock()
|
||||
sessionCount := 0
|
||||
for _, s := range a.sessions {
|
||||
if s.UserID == userID {
|
||||
sessionCount++
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
assert.Equal(t, 0, sessionCount)
|
||||
// Session should be gone (validate returns error)
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestDeleteUser_Bad(t *testing.T) {
|
||||
|
|
|
|||
100
auth/session_store.go
Normal file
100
auth/session_store.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSessionNotFound is returned when a session token is not found.
|
||||
var ErrSessionNotFound = errors.New("auth: session not found")
|
||||
|
||||
// SessionStore abstracts session persistence.
|
||||
type SessionStore interface {
|
||||
Get(token string) (*Session, error)
|
||||
Set(session *Session) error
|
||||
Delete(token string) error
|
||||
DeleteByUser(userID string) error
|
||||
Cleanup() (int, error) // Remove expired sessions, return count removed
|
||||
}
|
||||
|
||||
// MemorySessionStore is an in-memory SessionStore backed by a map.
|
||||
type MemorySessionStore struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
// NewMemorySessionStore creates a new in-memory session store.
|
||||
func NewMemorySessionStore() *MemorySessionStore {
|
||||
return &MemorySessionStore{
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a session by token.
|
||||
func (m *MemorySessionStore) Get(token string) (*Session, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
session, exists := m.sessions[token]
|
||||
if !exists {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
// Return a copy to prevent mutation outside the lock
|
||||
s := *session
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// Set stores a session, keyed by its token.
|
||||
func (m *MemorySessionStore) Set(session *Session) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Store a copy to prevent external mutation
|
||||
s := *session
|
||||
m.sessions[session.Token] = &s
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a session by token.
|
||||
func (m *MemorySessionStore) Delete(token string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if _, exists := m.sessions[token]; !exists {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
delete(m.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByUser removes all sessions belonging to the given user.
|
||||
func (m *MemorySessionStore) DeleteByUser(userID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for token, session := range m.sessions {
|
||||
if session.UserID == userID {
|
||||
delete(m.sessions, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup removes all expired sessions and returns the count removed.
|
||||
func (m *MemorySessionStore) Cleanup() (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
count := 0
|
||||
for token, session := range m.sessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(m.sessions, token)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
136
auth/session_store_sqlite.go
Normal file
136
auth/session_store_sqlite.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-store"
|
||||
)
|
||||
|
||||
const sessionGroup = "sessions"
|
||||
|
||||
// SQLiteSessionStore is a SessionStore backed by go-store (SQLite KV).
|
||||
// A mutex serialises all operations because SQLite is single-writer.
|
||||
type SQLiteSessionStore struct {
|
||||
mu sync.Mutex
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// NewSQLiteSessionStore creates a new SQLite-backed session store.
|
||||
// Use ":memory:" for testing or a file path for persistent storage.
|
||||
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) {
|
||||
s, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SQLiteSessionStore{store: s}, nil
|
||||
}
|
||||
|
||||
// Get retrieves a session by token from SQLite.
|
||||
func (s *SQLiteSessionStore) Get(token string) (*Session, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
val, err := s.store.Get(sessionGroup, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// Set stores a session in SQLite, keyed by its token.
|
||||
func (s *SQLiteSessionStore) Set(session *Session) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Set(sessionGroup, session.Token, string(data))
|
||||
}
|
||||
|
||||
// Delete removes a session by token from SQLite.
|
||||
func (s *SQLiteSessionStore) Delete(token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check existence first to return ErrSessionNotFound
|
||||
_, err := s.store.Get(sessionGroup, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.store.Delete(sessionGroup, token)
|
||||
}
|
||||
|
||||
// DeleteByUser removes all sessions belonging to the given user.
|
||||
func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
all, err := s.store.GetAll(sessionGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for token, val := range all {
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
continue // Skip malformed entries
|
||||
}
|
||||
if session.UserID == userID {
|
||||
if err := s.store.Delete(sessionGroup, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup removes all expired sessions and returns the count removed.
|
||||
func (s *SQLiteSessionStore) Cleanup() (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
all, err := s.store.GetAll(sessionGroup)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
count := 0
|
||||
for token, val := range all {
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
continue // Skip malformed entries
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
if err := s.store.Delete(sessionGroup, token); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying SQLite store.
|
||||
func (s *SQLiteSessionStore) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.store.Close()
|
||||
}
|
||||
503
auth/session_store_test.go
Normal file
503
auth/session_store_test.go
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"forge.lthn.ai/core/go-crypt/crypt/lthn"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
||||
// --- MemorySessionStore ---
|
||||
|
||||
func TestMemorySessionStore_GetSetDelete_Good(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
session := &Session{
|
||||
Token: "test-token-abc",
|
||||
UserID: "user-123",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
// Set
|
||||
err := store.Set(session)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get
|
||||
got, err := store.Get("test-token-abc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, got.Token)
|
||||
assert.Equal(t, session.UserID, got.UserID)
|
||||
|
||||
// Delete
|
||||
err = store.Delete("test-token-abc")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get after delete should fail
|
||||
_, err = store.Get("test-token-abc")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestMemorySessionStore_GetNotFound_Bad(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
_, err := store.Get("nonexistent-token")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestMemorySessionStore_DeleteNotFound_Bad(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
err := store.Delete("nonexistent-token")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
// Create sessions for two users
|
||||
for i := 0; i < 3; i++ {
|
||||
err := store.Set(&Session{
|
||||
Token: fmt.Sprintf("user-a-token-%d", i),
|
||||
UserID: "user-a",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err := store.Set(&Session{
|
||||
Token: "user-b-token",
|
||||
UserID: "user-b",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete all user-a sessions
|
||||
err = store.DeleteByUser("user-a")
|
||||
require.NoError(t, err)
|
||||
|
||||
// user-a sessions should be gone
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := store.Get(fmt.Sprintf("user-a-token-%d", i))
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
// user-b session should remain
|
||||
got, err := store.Get("user-b-token")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-b", got.UserID)
|
||||
}
|
||||
|
||||
func TestMemorySessionStore_Cleanup_Good(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
// Create expired and valid sessions
|
||||
err := store.Set(&Session{
|
||||
Token: "expired-1",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "expired-2",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(-30 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "valid-1",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := store.Cleanup()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
// Valid session should remain
|
||||
_, err = store.Get("valid-1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Expired sessions should be gone
|
||||
_, err = store.Get("expired-1")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
_, err = store.Get("expired-2")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestMemorySessionStore_Concurrent_Good(t *testing.T) {
|
||||
store := NewMemorySessionStore()
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := fmt.Sprintf("concurrent-token-%d", idx)
|
||||
|
||||
err := store.Set(&Session{
|
||||
Token: token,
|
||||
UserID: fmt.Sprintf("user-%d", idx%5),
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
got, err := store.Get(token)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, token, got.Token)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// --- SQLiteSessionStore ---
|
||||
|
||||
func TestSQLiteSessionStore_GetSetDelete_Good(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
session := &Session{
|
||||
Token: "sqlite-token-abc",
|
||||
UserID: "user-456",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
|
||||
// Set
|
||||
err = store.Set(session)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get
|
||||
got, err := store.Get("sqlite-token-abc")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, got.Token)
|
||||
assert.Equal(t, session.UserID, got.UserID)
|
||||
|
||||
// Delete
|
||||
err = store.Delete("sqlite-token-abc")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get after delete should fail
|
||||
_, err = store.Get("sqlite-token-abc")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_GetNotFound_Bad(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
_, err = store.Get("nonexistent-token")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_DeleteNotFound_Bad(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
err = store.Delete("nonexistent-token")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
// Create sessions for two users
|
||||
for i := 0; i < 3; i++ {
|
||||
err := store.Set(&Session{
|
||||
Token: fmt.Sprintf("sqlite-user-a-%d", i),
|
||||
UserID: "user-a",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "sqlite-user-b",
|
||||
UserID: "user-b",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete all user-a sessions
|
||||
err = store.DeleteByUser("user-a")
|
||||
require.NoError(t, err)
|
||||
|
||||
// user-a sessions should be gone
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err := store.Get(fmt.Sprintf("sqlite-user-a-%d", i))
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
// user-b session should remain
|
||||
got, err := store.Get("sqlite-user-b")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-b", got.UserID)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_Cleanup_Good(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
// Create expired and valid sessions
|
||||
err = store.Set(&Session{
|
||||
Token: "sqlite-expired-1",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "sqlite-expired-2",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(-30 * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "sqlite-valid-1",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := store.Cleanup()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
// Valid session should remain
|
||||
_, err = store.Get("sqlite-valid-1")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Expired sessions should be gone
|
||||
_, err = store.Get("sqlite-expired-1")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
_, err = store.Get("sqlite-expired-2")
|
||||
assert.ErrorIs(t, err, ErrSessionNotFound)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "sessions.db")
|
||||
|
||||
// Write a session
|
||||
store1, err := NewSQLiteSessionStore(dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
session := &Session{
|
||||
Token: "persist-token",
|
||||
UserID: "persist-user",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
err = store1.Set(session)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Close the store
|
||||
err = store1.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reopen and verify data persists
|
||||
store2, err := NewSQLiteSessionStore(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer store2.Close()
|
||||
|
||||
got, err := store2.Get("persist-token")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "persist-user", got.UserID)
|
||||
assert.Equal(t, "persist-token", got.Token)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) {
|
||||
// Use a temp file — :memory: SQLite has concurrency limitations
|
||||
dbPath := filepath.Join(t.TempDir(), "concurrent.db")
|
||||
store, err := NewSQLiteSessionStore(dbPath)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
token := fmt.Sprintf("sqlite-concurrent-%d", idx)
|
||||
|
||||
err := store.Set(&Session{
|
||||
Token: token,
|
||||
UserID: fmt.Sprintf("user-%d", idx%5),
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
got, err := store.Get(token)
|
||||
assert.NoError(t, err)
|
||||
if got != nil {
|
||||
assert.Equal(t, token, got.Token)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// --- Authenticator with SessionStore ---
|
||||
|
||||
func TestAuthenticator_WithSessionStore_Good(t *testing.T) {
|
||||
sqliteStore, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer sqliteStore.Close()
|
||||
|
||||
m := io.NewMockMedium()
|
||||
a := New(m, WithSessionStore(sqliteStore))
|
||||
|
||||
// Register user
|
||||
_, err = a.Register("store-test-user", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("store-test-user")
|
||||
|
||||
// Login creates session in SQLite store
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
|
||||
// Validate session from store
|
||||
validated, err := a.ValidateSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, validated.Token)
|
||||
assert.Equal(t, userID, validated.UserID)
|
||||
|
||||
// Refresh session
|
||||
refreshed, err := a.RefreshSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, refreshed.Token)
|
||||
|
||||
// Revoke session
|
||||
err = a.RevokeSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Session should be gone
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestAuthenticator_DefaultStore_Good(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
a := New(m)
|
||||
|
||||
// Default store should be MemorySessionStore
|
||||
_, ok := a.store.(*MemorySessionStore)
|
||||
assert.True(t, ok, "default store should be MemorySessionStore")
|
||||
}
|
||||
|
||||
func TestAuthenticator_StartCleanup_Good(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
a := New(m, WithSessionTTL(1*time.Millisecond))
|
||||
|
||||
// Register and login to create a session
|
||||
_, err := a.Register("cleanup-test", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("cleanup-test")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Wait for session to expire
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// Start cleanup with a short interval
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
a.StartCleanup(ctx, 10*time.Millisecond)
|
||||
|
||||
// Wait for at least one cleanup cycle
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Session should have been cleaned up
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestAuthenticator_StartCleanup_CancelStops_Good(t *testing.T) {
|
||||
m := io.NewMockMedium()
|
||||
a := New(m)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.StartCleanup(ctx, 10*time.Millisecond)
|
||||
|
||||
// Cancel should stop the goroutine without panic
|
||||
cancel()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_UpdateExisting_Good(t *testing.T) {
|
||||
store, err := NewSQLiteSessionStore(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
original := &Session{
|
||||
Token: "update-token",
|
||||
UserID: "user-1",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
err = store.Set(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update with new expiry
|
||||
updated := &Session{
|
||||
Token: "update-token",
|
||||
UserID: "user-1",
|
||||
ExpiresAt: time.Now().Add(2 * time.Hour),
|
||||
}
|
||||
err = store.Set(updated)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.Get("update-token")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.ExpiresAt.After(original.ExpiresAt),
|
||||
"updated session should have later expiry")
|
||||
}
|
||||
|
||||
func TestSQLiteSessionStore_TempFile_Good(t *testing.T) {
|
||||
// Verify we can use a real temp file (not :memory:)
|
||||
tmpFile := filepath.Join(os.TempDir(), "go-crypt-test-session-store.db")
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
store, err := NewSQLiteSessionStore(tmpFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Set(&Session{
|
||||
Token: "temp-file-token",
|
||||
UserID: "user",
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.Get("temp-file-token")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "temp-file-token", got.Token)
|
||||
|
||||
err = store.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
17
go.mod
17
go.mod
|
|
@ -4,6 +4,7 @@ go 1.25.5
|
|||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.0.0
|
||||
forge.lthn.ai/core/go-store v0.0.0
|
||||
github.com/ProtonMail/go-crypto v1.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
|
|
@ -12,9 +13,23 @@ require (
|
|||
require (
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/go => ../go
|
||||
replace (
|
||||
forge.lthn.ai/core/go => ../go
|
||||
forge.lthn.ai/core/go-store => ../go-store
|
||||
)
|
||||
|
|
|
|||
63
go.sum
63
go.sum
|
|
@ -2,17 +2,78 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
|
|||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue