clipboard: survive corrupted bbolt db

fixes #3232
This commit is contained in:
bbedward
2026-08-30 11:16:36 -04:00
parent 9bd31e30f4
commit c68b7e5333
3 changed files with 224 additions and 45 deletions
+9 -1
View File
@@ -47,7 +47,15 @@ func Store(data []byte, mimeType string) error {
return StoreWithConfig(data, mimeType, DefaultStoreConfig())
}
func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error {
func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
err = fmt.Errorf("clipboard db panic: %v", r)
}()
if len(data) == 0 {
return nil
}
+154 -44
View File
@@ -113,7 +113,61 @@ func NewManager(wlCtx wlcontext.WaylandContext, config Config) (*Manager, error)
}
func openDB(path string) (*bolt.DB, error) {
db, err := bolt.Open(path, 0o644, &bolt.Options{
db, err := tryOpenDB(path)
if err == nil {
return db, nil
}
log.Errorf("Clipboard db corrupted, salvaging readable entries: %v", err)
salvagePath := path + ".salvage"
os.Remove(salvagePath)
// chunked commits keep entries copied before the first unreadable page
if salvageErr := compactInto(path, salvagePath, 64<<10); salvageErr != nil {
log.Warnf("Clipboard db salvage stopped early: %v", salvageErr)
}
corruptPath := path + ".corrupt"
os.Remove(corruptPath)
if renameErr := os.Rename(path, corruptPath); renameErr != nil {
os.Remove(salvagePath)
return nil, err
}
os.Rename(salvagePath, path)
db, err = tryOpenDB(path)
if err == nil {
return db, nil
}
os.Remove(path)
return tryOpenDB(path)
}
// bbolt reports corrupted pages by panicking (internal/common/verify.go), not
// by returning errors, so every db touchpoint recovers and converts to error.
func recoverDBPanic(err *error) {
r := recover()
if r == nil {
return
}
*err = fmt.Errorf("clipboard db panic: %v", r)
}
func tryOpenDB(path string) (db *bolt.DB, err error) {
defer func() {
r := recover()
if r == nil {
return
}
if db != nil {
db.Close()
}
db, err = nil, fmt.Errorf("clipboard db panic: %v", r)
}()
db, err = bolt.Open(path, 0o644, &bolt.Options{
Timeout: 1 * time.Second,
})
if err != nil {
@@ -129,9 +183,31 @@ func openDB(path string) (*bolt.DB, error) {
return nil, err
}
err = db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
return b.ForEach(func(k, v []byte) error { return nil })
})
if err != nil {
db.Close()
return nil, err
}
return db, nil
}
func (m *Manager) dbUpdate(fn func(tx *bolt.Tx) error) (err error) {
defer recoverDBPanic(&err)
return m.db.Update(fn)
}
func (m *Manager) dbView(fn func(tx *bolt.Tx) error) (err error) {
defer recoverDBPanic(&err)
return m.db.View(fn)
}
func (m *Manager) post(fn func()) {
m.wlCtx.Post(fn)
}
@@ -444,8 +520,11 @@ func (m *Manager) storeEntry(entry Entry) error {
entry.Hash = computeHash(entry.Data)
return m.db.Update(func(tx *bolt.Tx) error {
return m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("clipboard bucket missing")
}
if err := m.deduplicateInTx(b, entry.Hash); err != nil {
return err
@@ -908,8 +987,11 @@ func (m *Manager) GetHistory() []Entry {
var history []Entry
var stale []uint64
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
c := b.Cursor()
for k, v := c.Last(); k != nil; k, v = c.Prev() {
@@ -940,8 +1022,11 @@ func (m *Manager) deleteStaleEntries(ids []uint64) {
return
}
if err := m.db.Update(func(tx *bolt.Tx) error {
if err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
for _, id := range ids {
if err := b.Delete(itob(id)); err != nil {
log.Errorf("Failed to delete stale entry %d: %v", id, err)
@@ -961,8 +1046,11 @@ func (m *Manager) GetEntry(id uint64) (*Entry, error) {
var entry Entry
var found bool
err := m.db.View(func(tx *bolt.Tx) error {
err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
v := b.Get(itob(id))
if v == nil {
return nil
@@ -992,8 +1080,11 @@ func (m *Manager) DeleteEntry(id uint64) error {
return fmt.Errorf("database not available")
}
err := m.db.Update(func(tx *bolt.Tx) error {
err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
}
return b.Delete(itob(id))
})
@@ -1061,7 +1152,7 @@ func (m *Manager) ClearHistory() {
}
// Delete only non-pinned entries
if err := m.db.Update(func(tx *bolt.Tx) error {
if err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1088,7 +1179,7 @@ func (m *Manager) ClearHistory() {
}
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b != nil {
c := b.Cursor()
@@ -1120,42 +1211,55 @@ func (m *Manager) compactDB() error {
tmpPath := m.dbPath + ".compact"
defer os.Remove(tmpPath)
srcDB, err := bolt.Open(m.dbPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
if err != nil {
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open source: %w", err)
if err := compactInto(m.dbPath, tmpPath, 0); err != nil {
m.reopenDB()
return err
}
dstDB, err := bolt.Open(tmpPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil {
srcDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("open destination: %w", err)
}
if err := bolt.Compact(dstDB, srcDB, 0); err != nil {
srcDB.Close()
dstDB.Close()
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
return fmt.Errorf("compact: %w", err)
}
srcDB.Close()
dstDB.Close()
if err := os.Rename(tmpPath, m.dbPath); err != nil {
m.db, _ = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
m.reopenDB()
return fmt.Errorf("rename: %w", err)
}
m.db, err = bolt.Open(m.dbPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil {
return fmt.Errorf("reopen: %w", err)
m.reopenDB()
if m.db == nil {
return fmt.Errorf("reopen failed")
}
return nil
}
func compactInto(srcPath, dstPath string, txMaxSize int64) (err error) {
defer recoverDBPanic(&err)
srcDB, err := bolt.Open(srcPath, 0o644, &bolt.Options{ReadOnly: true, Timeout: time.Second})
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer srcDB.Close()
dstDB, err := bolt.Open(dstPath, 0o644, &bolt.Options{Timeout: time.Second})
if err != nil {
return fmt.Errorf("open destination: %w", err)
}
defer dstDB.Close()
if err := bolt.Compact(dstDB, srcDB, txMaxSize); err != nil {
return fmt.Errorf("compact: %w", err)
}
return nil
}
func (m *Manager) reopenDB() {
db, err := openDB(m.dbPath)
if err != nil {
log.Errorf("Failed to reopen clipboard db: %v", err)
m.db = nil
return
}
m.db = db
}
func (m *Manager) SetClipboard(data []byte, mimeType string) error {
if int64(len(data)) > m.config.MaxEntrySize {
return fmt.Errorf("data too large")
@@ -1339,7 +1443,7 @@ func (m *Manager) Close() {
}
func (m *Manager) clearHistoryInternal() error {
return m.db.Update(func(tx *bolt.Tx) error {
return m.dbUpdate(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte("clipboard")); err != nil {
return err
}
@@ -1351,7 +1455,7 @@ func (m *Manager) clearHistoryInternal() error {
func (m *Manager) clearOldEntries(days int) error {
cutoff := time.Now().AddDate(0, 0, -days)
return m.db.Update(func(tx *bolt.Tx) error {
return m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1387,7 +1491,7 @@ func (m *Manager) migrateHashes() error {
}
var needsMigration bool
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1410,7 +1514,7 @@ func (m *Manager) migrateHashes() error {
log.Info("Migrating clipboard entries to add hashes...")
return m.db.Update(func(tx *bolt.Tx) error {
return m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1470,7 +1574,7 @@ func (m *Manager) Search(params SearchParams) SearchResult {
mimeFilter := strings.ToLower(params.MimeType)
var all []Entry
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1664,7 +1768,7 @@ func (m *Manager) PinEntry(id uint64) error {
}
var hashExists bool
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1691,7 +1795,7 @@ func (m *Manager) PinEntry(id uint64) error {
cfg := m.getConfig()
pinnedCount := 0
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1712,8 +1816,11 @@ func (m *Manager) PinEntry(id uint64) error {
return fmt.Errorf("maximum pinned entries reached (%d)", cfg.MaxPinned)
}
err = m.db.Update(func(tx *bolt.Tx) error {
err = m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("entry not found")
}
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
@@ -1746,8 +1853,11 @@ func (m *Manager) UnpinEntry(id uint64) error {
return fmt.Errorf("database not available")
}
err := m.db.Update(func(tx *bolt.Tx) error {
err := m.dbUpdate(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return fmt.Errorf("entry not found")
}
v := b.Get(itob(id))
if v == nil {
return fmt.Errorf("entry not found")
@@ -1812,7 +1922,7 @@ func (m *Manager) GetPinnedEntries() []Entry {
}
var pinned []Entry
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -1842,7 +1952,7 @@ func (m *Manager) GetPinnedCount() int {
}
count := 0
if err := m.db.View(func(tx *bolt.Tx) error {
if err := m.dbView(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("clipboard"))
if b == nil {
return nil
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"net"
"os"
"path/filepath"
"sync"
"sync/atomic"
@@ -792,3 +793,63 @@ func TestManager_ConcurrentPostWithMock(t *testing.T) {
wg.Wait()
assert.Equal(t, int32(100), postCount.Load())
}
func corruptInlineBucketRoot(t *testing.T, path string) {
t.Helper()
f, err := os.OpenFile(path, os.O_RDWR, 0o644)
require.NoError(t, err)
defer f.Close()
// flips the inline bucket root pgid to an out-of-range page, per issue #3232
_, err = f.WriteAt([]byte{16}, int64(4*os.Getpagesize()+32+9))
require.NoError(t, err)
}
func TestOpenDB_HealsCorruptedBucketPage(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := openDB(path)
require.NoError(t, err)
require.NoError(t, db.Close())
corruptInlineBucketRoot(t, path)
db, err = openDB(path)
require.NoError(t, err)
defer db.Close()
m := &Manager{config: DefaultConfig(), db: db, dbPath: path}
require.NoError(t, m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"}))
assert.Len(t, m.GetHistory(), 1)
}
func TestOpenDB_HealsGarbageFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
require.NoError(t, os.WriteFile(path, bytes.Repeat([]byte{0xAB}, 64<<10), 0o644))
db, err := openDB(path)
require.NoError(t, err)
defer db.Close()
_, err = os.Stat(path + ".corrupt")
require.NoError(t, err)
}
func TestStoreEntry_CorruptDBReturnsErrorNotPanic(t *testing.T) {
path := filepath.Join(t.TempDir(), "db")
db, err := openDB(path)
require.NoError(t, err)
require.NoError(t, db.Close())
corruptInlineBucketRoot(t, path)
db, err = bolt.Open(path, 0o644, &bolt.Options{Timeout: time.Second})
require.NoError(t, err)
defer db.Close()
m := &Manager{config: DefaultConfig(), db: db, dbPath: path}
_ = m.storeEntry(Entry{Data: []byte("x"), MimeType: "text/plain"})
_ = m.GetHistory()
}