From 132c4a1ba2591b5c0ce24c3858cc6ca26e1fe258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A0=81=E5=86=9C=E9=98=BF=E6=A5=A0?= Date: Tue, 8 Sep 2026 17:32:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(store):=20=E4=BF=AE=E6=AD=A3=E9=82=BB?= =?UTF-8?q?=E6=8E=A5=E6=9F=A5=E8=AF=A2=E3=80=81GC=20=E7=AB=9E=E6=80=81?= =?UTF-8?q?=E3=80=81=E5=A4=87=E4=BB=BD=E6=9D=83=E9=99=90=E4=B8=8E=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E8=A7=A3=E6=9E=90=EF=BC=8C=E6=96=B0=E5=A2=9E=20OpenDa?= =?UTF-8?q?ta=20=E7=BA=AF=E6=95=B0=E6=8D=AE=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。 --- internal/store/maint.go | 29 ++++++++++-- internal/store/notes.go | 21 ++++---- internal/store/settings.go | 7 +-- internal/store/store.go | 39 +++++++++++---- internal/store/store_test.go | 92 ++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 26 deletions(-) diff --git a/internal/store/maint.go b/internal/store/maint.go index 022bb8d..6481220 100644 --- a/internal/store/maint.go +++ b/internal/store/maint.go @@ -3,6 +3,7 @@ package store import ( "fmt" "os" + "syscall" "time" ) @@ -86,15 +87,29 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) { return rep, nil } + // 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被 + // 重新引用时,不删活数据;评审 round2 P2-6) for _, en := range rep.ExpiredNotes { - if err := s.DeleteNoteForever(en.ID); err != nil { + res, err := s.db.Exec( + `DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`, + en.ID, noteCutoff) + if err != nil { return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err) } + if rows, _ := res.RowsAffected(); rows == 0 { + continue // 期间已被恢复,跳过 + } } for _, img := range rep.OrphanImages { - if err := s.DeleteImage(img.ID); err != nil { + res, err := s.db.Exec( + `DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`, + img.ID, img.ID) + if err != nil { return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err) } + if rows, _ := res.RowsAffected(); rows == 0 { + continue // 期间已被引用,跳过 + } } n, err := s.DeleteExpiredSessions(now.Unix()) if err != nil { @@ -109,11 +124,17 @@ func (s *Store) Backup(destPath string) error { if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("目标文件已存在: %s", destPath) } + // 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600 + // (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。 + // backup 为 CLI 单线程进程,进程级 umask 无并发副作用。 + old := syscall.Umask(0o077) // VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。 - if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil { + _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)) + syscall.Umask(old) + if err != nil { return err } - // 备份含全部私钥内容:强制 0600(§10.3) + // 双保险:显式收紧(幂等) if err := os.Chmod(destPath, 0o600); err != nil { return err } diff --git a/internal/store/notes.go b/internal/store/notes.go index bb0d245..1bb03cd 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -183,23 +183,26 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er return out, total, rows.Err() } -// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇 -// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。 +// AdjacentPublicNote 返回公开列表中与 n 相邻的上一篇/下一篇。 +// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes), +// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项), +// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。 func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) { + p := boolToInt(n.Pinned) var q string switch dir { - case "prev": // 列表中更早的一篇 + case "prev": // 列表中位于 n 之前的一篇 q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + ` - AND (updated_at > ? OR (updated_at = ? AND id < ?)) - ORDER BY updated_at ASC, id DESC LIMIT 1` - case "next": // 列表中更新的一篇 + AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?)))) + ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1` + case "next": // 列表中位于 n 之后的一篇 q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + ` - AND (updated_at < ? OR (updated_at = ? AND id > ?)) - ORDER BY updated_at DESC, id ASC LIMIT 1` + AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?)))) + ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1` default: return "", "", fmt.Errorf("dir 必须为 prev|next") } - row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID) + row := s.db.QueryRow(q, p, p, n.UpdatedAt, n.UpdatedAt, n.ID) if err := row.Scan(&slug, &title); err != nil { if errors.Is(err, sql.ErrNoRows) { return "", "", nil diff --git a/internal/store/settings.go b/internal/store/settings.go index 4b1011b..23b1b76 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -3,7 +3,8 @@ package store import ( "database/sql" "errors" - "fmt" + "strconv" + "strings" ) // Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。 @@ -61,8 +62,8 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) { if v, ok, err := s.GetSetting(KeyPageSize); err != nil { return nil, err } else if ok { - var n int - if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 1 && n <= 100 { + // strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13) + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 { ss.PageSize = n } } diff --git a/internal/store/store.go b/internal/store/store.go index 8517bb5..63de246 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -80,8 +80,32 @@ type Store struct { db *sql.DB } -// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。 +// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。 +// allowNewer 跳过 user_version 上界守卫(§10.4)。 func Open(dbPath string, allowNewer bool) (*Store, error) { + db, err := openDB(dbPath) + if err != nil { + return nil, err + } + if err := migrateDB(db, allowNewer); err != nil { + db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫** +// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理 +// 不应被守卫阻断,也不应产生迁移副作用)。 +func OpenData(dbPath string) (*Store, error) { + db, err := openDB(dbPath) + if err != nil { + return nil, err + } + return &Store{db: db}, nil +} + +func openDB(dbPath string) (*sql.DB, error) { // SQLite URI 不接受相对路径 abs, err := filepath.Abs(dbPath) if err != nil { @@ -101,24 +125,19 @@ func Open(dbPath string, allowNewer bool) (*Store, error) { } // 单写者串行化(§6.2) db.SetMaxOpenConns(1) - s := &Store{db: db} - if err := s.migrate(allowNewer); err != nil { - db.Close() - return nil, err - } - return s, nil + return db, nil } -func (s *Store) migrate(allowNewer bool) error { +func migrateDB(db *sql.DB, allowNewer bool) error { var v int - if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil { + if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil { return err } if v > MaxSchemaVersion && !allowNewer { return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion) } for i := v; i < MaxSchemaVersion; i++ { - tx, err := s.db.Begin() + tx, err := db.Begin() if err != nil { return err } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index af60f6f..d55f67a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -97,6 +97,98 @@ func TestForeignKeysCascade(t *testing.T) { } } +// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。 +func TestOpenDataNoMigrate(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "pn.db") + + // 全新路径:OpenData 不创建 schema + s, err := OpenData(dbPath) + if err != nil { + t.Fatal(err) + } + v, err := s.SchemaVersion() + if err != nil { + t.Fatal(err) + } + if v != 0 { + t.Fatalf("OpenData 不应执行迁移,user_version=%d", v) + } + var one int + if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil { + t.Fatal("OpenData 不应创建表") + } + s.Close() + + // 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用) + s2, err := Open(dbPath, false) + if err != nil { + t.Fatal(err) + } + if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil { + t.Fatal(err) + } + s2.Close() + if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) { + t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err) + } + s3, err := OpenData(dbPath) + if err != nil { + t.Fatalf("OpenData 不做版本守卫,应放行: %v", err) + } + s3.Close() +} + +// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC +// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。 +func TestAdjacentPublicNote(t *testing.T) { + s := openTestStore(t) + mk := func(slug string, at int64, pinned bool, status string) { + t.Helper() + if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status, + Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil { + t.Fatal(err) + } + } + mk("a", 100, false, "public") // id=1 + mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前) + mk("c", 80, false, "public") // id=3 + mk("p", 50, true, "public") // id=4 置顶 → 列表首 + mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中 + + get := func(slug string) *Note { + n, err := s.GetNoteBySlug(slug) + if err != nil { + t.Fatal(err) + } + return n + } + // 公开列表序:p > b > a > c(priv 被过滤) + cases := []struct{ slug, prev, next string }{ + {"p", "", "b"}, // 置顶首篇 + {"b", "p", "a"}, + {"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过 + {"c", "a", ""}, + } + for _, tc := range cases { + prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev") + if err != nil { + t.Fatalf("%s prev: %v", tc.slug, err) + } + nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next") + if err != nil { + t.Fatalf("%s next: %v", tc.slug, err) + } + if prevSlug != tc.prev || nextSlug != tc.next { + t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q", + tc.slug, tc.prev, tc.next, prevSlug, nextSlug) + } + } + if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil { + t.Error("非法 dir 应返回错误") + } +} + func openTestStore(t *testing.T) *Store { t.Helper() s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)