3bea73f857
A literal "~/..." in EMCLI_DB has no shell to expand it, so SQLite opened it relative to the cwd and silently created a stray "~" directory tree. Expand a leading "~" or "~/" to the user's home dir; "~user", mid-path tildes, and absolute/relative paths are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
3.2 KiB
Go
111 lines
3.2 KiB
Go
// Package store owns the encrypted SQLite config and read state.
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Store wraps the database and the field-encryption key.
|
|
type Store struct {
|
|
db *sql.DB
|
|
key []byte
|
|
}
|
|
|
|
// Open opens (creating if needed) the DB at path and applies the schema.
|
|
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
|
|
// secret read/write.
|
|
func Open(path string) (*Store, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return nil, fmt.Errorf("create db dir: %w", err)
|
|
}
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Pin pool to a single connection so PRAGMA foreign_keys = ON sticks.
|
|
// SQLite PRAGMAs are connection-scoped; pool would otherwise create
|
|
// new connections without the pragma set.
|
|
db.SetMaxOpenConns(1)
|
|
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
if _, err := db.Exec(schemaSQL); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("apply schema: %w", err)
|
|
}
|
|
s := &Store{db: db}
|
|
if err := s.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// migrate brings an existing database up to the current schemaVersion. A brand-
|
|
// new database (no schema_version yet) already has every column from schemaSQL,
|
|
// so it is simply stamped at the current version. Each older version runs its
|
|
// forward step. The version gate makes every step idempotent across reopens.
|
|
func (s *Store) migrate() error {
|
|
v, err := s.GetSetting("schema_version")
|
|
if err != nil {
|
|
// Fresh database: schemaSQL created all columns already.
|
|
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
|
|
}
|
|
var ver int
|
|
ver, err = strconv.Atoi(v)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid schema_version %q: %w", v, err)
|
|
}
|
|
if ver < 2 {
|
|
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
|
|
return fmt.Errorf("migrate to v2: %w", err)
|
|
}
|
|
if err := s.SetSetting("schema_version", "2"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Close() error { return s.db.Close() }
|
|
|
|
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
|
|
// directory. Only a leading tilde is expanded (the usual shell convention) —
|
|
// "~user" and a tilde elsewhere in the path are left untouched. This guards
|
|
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
|
|
// would otherwise be opened relative to the cwd and create a stray "~" dir.
|
|
func expandUserHome(p string) string {
|
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
|
|
func DefaultDBPath() (string, error) {
|
|
if p := os.Getenv("EMCLI_DB"); p != "" {
|
|
return expandUserHome(p), nil
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
if dir := os.Getenv("AppData"); dir != "" {
|
|
return filepath.Join(dir, "emcli", "emcli.db"), nil
|
|
}
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".config", "emcli", "emcli.db"), nil
|
|
}
|