// Package spool owns the durable, command-local state retained by a client // daemon between network sessions and process restarts. package spool import ( "context" "crypto/sha256" "database/sql" "errors" "fmt" "net/url" "os" "path/filepath" "strconv" "sync" "time" "github.com/rvbox/rvbox/internal/domain" _ "modernc.org/sqlite" ) var ( ErrUnsafeDataDirectory = errors.New("unsafe client spool directory") ErrAlreadyOpen = errors.New("client spool directory is already locked") ErrIdentityCorrupt = errors.New("client instance identity is corrupt") ErrCommandConflict = errors.New("command UUID has different immutable content") ErrAlreadyExecuted = errors.New("command UUID was already executed") ErrUnknownCommand = errors.New("unknown client command") ErrInvalidEventAck = errors.New("event acknowledgement is beyond assigned sequence") ErrEventsPending = errors.New("command has events pending server acknowledgement") ) const DefaultTombstoneLimit uint64 = 1_000_000 type Options struct { DataDir string BusyTimeout time.Duration TombstoneLimit uint64 QuotaLimits QuotaLimits } type Store struct { db *sql.DB unlock func() error dataDir string identity domain.UUID tombstoneLimit uint64 quotaLimits QuotaLimits mu sync.Mutex } // Open recovers an existing spool or creates an empty one. The caller must // surface ErrIdentityCorrupt as dirty health; it is deliberately never healed // by assigning a new client identity. func Open(ctx context.Context, options Options) (*Store, error) { if !filepath.IsAbs(options.DataDir) || filepath.Clean(options.DataDir) == string(filepath.Separator) { return nil, ErrUnsafeDataDirectory } if options.BusyTimeout <= 0 { return nil, errors.New("busy timeout must be positive") } if options.TombstoneLimit == 0 { options.TombstoneLimit = DefaultTombstoneLimit } if options.QuotaLimits == (QuotaLimits{}) { options.QuotaLimits = DefaultQuotaLimits() } if err := options.QuotaLimits.Validate(); err != nil { return nil, err } if err := ensurePrivateDirectory(options.DataDir); err != nil { return nil, err } unlock, err := acquireInstanceLock(filepath.Join(options.DataDir, "spool.lock")) if err != nil { return nil, err } identity, err := loadOrCreateIdentity(filepath.Join(options.DataDir, "client-instance-id"), domain.NewUUIDv7) if err != nil { _ = unlock() return nil, err } databasePath := filepath.Join(options.DataDir, "spool.db") if err := ensurePrivateFile(databasePath); err != nil { _ = unlock() return nil, err } query := url.Values{} query.Add("_defensive", "1") query.Add("_pragma", "journal_mode(WAL)") query.Add("_pragma", "foreign_keys(ON)") query.Add("_pragma", "synchronous(FULL)") query.Add("_pragma", "busy_timeout("+strconv.FormatInt(options.BusyTimeout.Milliseconds(), 10)+")") databaseURL := &url.URL{Scheme: "file", Path: filepath.ToSlash(databasePath), RawQuery: query.Encode()} db, err := sql.Open("sqlite", databaseURL.String()) if err != nil { _ = unlock() return nil, err } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) store := &Store{db: db, unlock: unlock, dataDir: options.DataDir, identity: identity, tombstoneLimit: options.TombstoneLimit, quotaLimits: options.QuotaLimits} if err := db.PingContext(ctx); err != nil { _ = store.Close() return nil, fmt.Errorf("open client spool SQLite: %w", err) } if err := applyMigrations(ctx, db); err != nil { _ = store.Close() return nil, err } return store, nil } func (store *Store) ClientInstanceID() domain.UUID { return store.identity } func (store *Store) Close() error { if store == nil { return nil } store.mu.Lock() defer store.mu.Unlock() var result error if store.db != nil { result = store.db.Close() store.db = nil } if store.unlock != nil { if err := store.unlock(); result == nil { result = err } store.unlock = nil } return result } func ensurePrivateFile(path string) error { file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600) if err == nil { return file.Close() } if !errors.Is(err, os.ErrExist) { return err } info, err := os.Lstat(path) if err != nil { return err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("%w: %s is not a regular file", ErrUnsafeDataDirectory, path) } if info.Mode().Perm()&0o077 != 0 { return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) } return nil } func ensurePrivateDirectory(path string) error { info, err := os.Lstat(path) if os.IsNotExist(err) { if err := os.MkdirAll(path, 0o700); err != nil { return err } info, err = os.Lstat(path) } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return fmt.Errorf("%w: %s is not a real directory", ErrUnsafeDataDirectory, path) } if info.Mode().Perm()&0o077 != 0 { return fmt.Errorf("%w: %s permissions %04o expose private state", ErrUnsafeDataDirectory, path, info.Mode().Perm()) } return nil } func immutableDigest(payload []byte) [32]byte { return sha256.Sum256(payload) }