From 1408982782050e18462e6ae262b89e7a3dad9ad7 Mon Sep 17 00:00:00 2001 From: eezd Date: Sun, 25 Oct 2026 13:21:39 +0800 Subject: [PATCH] feat(backup): add validated archives and recoverable restore journals --- internal/backup/archive.go | 294 ++++++++++++++++++++++++++ internal/backup/backup_test.go | 214 +++++++++++++++++++ internal/backup/log.go | 95 +++++++++ internal/backup/restore.go | 373 +++++++++++++++++++++++++++++++++ internal/store/backup.go | 92 ++++++++ 5 files changed, 1068 insertions(+) create mode 100644 internal/backup/archive.go create mode 100644 internal/backup/backup_test.go create mode 100644 internal/backup/log.go create mode 100644 internal/backup/restore.go create mode 100644 internal/store/backup.go diff --git a/internal/backup/archive.go b/internal/backup/archive.go new file mode 100644 index 0000000..353e85a --- /dev/null +++ b/internal/backup/archive.go @@ -0,0 +1,294 @@ +// Package backup 处理 RawMark 自有的实例与设置归档;普通 Markdown 导出由 vault 负责。 +package backup + +import ( + "archive/zip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "syscall" + "time" +) + +const MaxArchiveBytes int64 = 2 << 30 +const MaxExpandedBytes int64 = 8 << 30 +const MaxEntries = 100000 + +type Entry struct { + Path string `json:"path"` + Size int64 `json:"size"` + SHA256 string `json:"sha256,omitempty"` + Dir bool `json:"dir,omitempty"` + Modified int64 `json:"modified"` +} +type Manifest struct { + Format int `json:"format"` + Kind string `json:"kind"` + CreatedAt int64 `json:"createdAt"` + Entries []Entry `json:"entries"` +} +type Source struct{ Name, Path string } + +func validPath(name string) bool { + return name != "" && path.Clean(name) == name && name != "." && !strings.HasPrefix(name, "/") && name != ".." && !strings.HasPrefix(name, "../") && !strings.ContainsAny(name, "\\\x00") +} +func validEntry(kind, name string) bool { + if !validPath(name) { + return false + } + for _, prefix := range []string{"app/fonts", "app/themes", "app/plugins"} { + if name == prefix || strings.HasPrefix(name, prefix+"/") { + return true + } + } + if kind == "full" { + return name == "app/rawmark.db" || name == "vault" || strings.HasPrefix(name, "vault/") + } + return kind == "settings" && name == "settings.json" +} + +// Create 不追踪符号链接,任何无法读取的文件都使备份明确失败。 +func Create(ctx context.Context, dest, kind string, sources []Source, skip func(string) bool) (err error) { + if kind != "full" && kind != "settings" { + return fmt.Errorf("备份类型无效") + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + if err != nil { + os.Remove(dest) + } + }() + z := zip.NewWriter(f) + manifest := Manifest{Format: 1, Kind: kind, CreatedAt: time.Now().UnixMilli(), Entries: []Entry{}} + names := map[string]bool{} + var total int64 + for _, source := range sources { + err = filepath.WalkDir(source.Path, func(full string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + rel, err := filepath.Rel(source.Path, full) + if err != nil { + return err + } + name := source.Name + if rel != "." { + name += "/" + filepath.ToSlash(rel) + } + if skip != nil && skip(name) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if !validEntry(kind, name) || names[name] { + return fmt.Errorf("备份路径无效或重复: %s", name) + } + names[name] = true + info, err := d.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() && !info.IsDir() { + return fmt.Errorf("备份不支持符号链接或特殊文件: %s", name) + } + e := Entry{Path: name, Dir: info.IsDir(), Modified: info.ModTime().UnixMilli()} + if len(manifest.Entries) >= MaxEntries { + return fmt.Errorf("备份文件数超过上限") + } + h, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + h.Name = name + h.Method = zip.Deflate + if e.Dir { + h.Name += "/" + } + out, err := z.CreateHeader(h) + if err != nil { + return err + } + if !e.Dir { + in, err := os.Open(full) + if err != nil { + return err + } + sum := sha256.New() + e.Size, err = io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, MaxExpandedBytes-total+1)) + err = errors.Join(err, in.Close()) + if err != nil { + return err + } + total += e.Size + if total > MaxExpandedBytes { + return fmt.Errorf("备份展开大小超过 8 GiB") + } + if e.Size != info.Size() { + return fmt.Errorf("备份期间文件发生变化: %s", name) + } + after, err := os.Stat(full) + if err != nil { + return err + } + if !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) || info.Size() != after.Size() { + return fmt.Errorf("备份期间文件发生变化: %s", name) + } + e.SHA256 = hex.EncodeToString(sum.Sum(nil)) + } + manifest.Entries = append(manifest.Entries, e) + return nil + }) + if err != nil { + return err + } + } + out, err := z.Create("manifest.json") + if err != nil { + return err + } + if err = json.NewEncoder(out).Encode(manifest); err != nil { + return err + } + if err = z.Close(); err != nil { + return err + } + info, err := f.Stat() + if err != nil { + return err + } + if info.Size() > MaxArchiveBytes { + return fmt.Errorf("备份压缩大小超过 2 GiB") + } + return f.Sync() +} + +// Extract 在私有暂存目录校验完整清单和全部文件哈希,绝不写入活动数据目录。 +func Extract(ctx context.Context, archive, dest string) (Manifest, error) { + var manifest Manifest + z, err := zip.OpenReader(archive) + if err != nil { + return manifest, err + } + defer z.Close() + if len(z.File) > MaxEntries+1 { + return manifest, fmt.Errorf("备份文件数超过上限") + } + entries := map[string]*zip.File{} + for _, f := range z.File { + name := strings.TrimSuffix(f.Name, "/") + if !validPath(name) || entries[name] != nil { + return manifest, fmt.Errorf("归档路径无效或重复") + } + if !f.Mode().IsRegular() && !f.Mode().IsDir() { + return manifest, fmt.Errorf("归档不允许符号链接或特殊文件") + } + entries[name] = f + } + m := entries["manifest.json"] + if m == nil || m.UncompressedSize64 > 32<<20 { + return manifest, fmt.Errorf("缺少有效备份清单") + } + r, err := m.Open() + if err != nil { + return manifest, err + } + err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest) + err = errors.Join(err, r.Close()) + if err != nil { + return manifest, err + } + if manifest.Format != 1 || (manifest.Kind != "full" && manifest.Kind != "settings") || len(manifest.Entries) != len(entries)-1 { + return manifest, fmt.Errorf("备份版本、类型或清单无效") + } + var expanded int64 + for _, e := range manifest.Entries { + if e.Size < 0 || e.Size > MaxExpandedBytes-expanded { + return manifest, fmt.Errorf("备份展开大小超过 8 GiB") + } + expanded += e.Size + } + var disk syscall.Statfs_t + if err := syscall.Statfs(dest, &disk); err != nil { + return manifest, err + } + if uint64(expanded)+(32<<20) > disk.Bavail*uint64(disk.Bsize) { + return manifest, fmt.Errorf("磁盘空间不足") + } + seen := map[string]bool{} + var total int64 + for _, e := range manifest.Entries { + if err := ctx.Err(); err != nil { + return manifest, err + } + f := entries[e.Path] + if !validEntry(manifest.Kind, e.Path) || seen[e.Path] || f == nil || e.Size < 0 || e.Dir != f.FileInfo().IsDir() || uint64(e.Size) != f.UncompressedSize64 { + return manifest, fmt.Errorf("备份清单与文件不符") + } + seen[e.Path] = true + total += e.Size + if total > MaxExpandedBytes { + return manifest, fmt.Errorf("备份展开大小超过 8 GiB") + } + target := filepath.Join(dest, filepath.FromSlash(e.Path)) + if e.Dir { + if e.Size != 0 { + return manifest, fmt.Errorf("目录大小无效") + } + if err := os.MkdirAll(target, 0700); err != nil { + return manifest, err + } + continue + } + if len(e.SHA256) != 64 { + return manifest, fmt.Errorf("文件校验值无效") + } + if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil { + return manifest, err + } + in, err := f.Open() + if err != nil { + return manifest, err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + in.Close() + return manifest, err + } + sum := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(out, sum), io.LimitReader(in, e.Size+1)) + err = errors.Join(copyErr, in.Close(), out.Close()) + if err != nil { + return manifest, err + } + if n != e.Size || hex.EncodeToString(sum.Sum(nil)) != e.SHA256 { + return manifest, fmt.Errorf("文件校验失败: %s", e.Path) + } + t := time.UnixMilli(e.Modified) + if err := os.Chtimes(target, t, t); err != nil { + return manifest, err + } + } + if manifest.Kind == "full" && (!seen["app/rawmark.db"] || !seen["vault"]) { + return manifest, fmt.Errorf("实例备份缺少数据库或笔记库") + } + if manifest.Kind == "settings" && !seen["settings.json"] { + return manifest, fmt.Errorf("设置备份缺少设置") + } + return manifest, nil +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..e1cec2a --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,214 @@ +package backup + +import ( + "archive/zip" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "rawmark/internal/auth" + "rawmark/internal/store" +) + +func writeTest(t *testing.T, file, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(content), 0600); err != nil { + t.Fatal(err) + } +} +func database(t *testing.T, file string) { + t.Helper() + st, err := store.Open(file) + if err != nil { + t.Fatal(err) + } + if err = auth.New(st).Setup("disposable-backup-password"); err != nil { + t.Fatal(err) + } + if err = st.Close(); err != nil { + t.Fatal(err) + } +} +func fullArchive(t *testing.T, dir, content string) string { + t.Helper() + source := filepath.Join(dir, "source") + os.MkdirAll(source, 0700) + database(t, filepath.Join(source, "rawmark.db")) + writeTest(t, filepath.Join(source, "vault/note.md"), content) + archive := filepath.Join(dir, "backup.zip") + if err := Create(context.Background(), archive, "full", []Source{{"app/rawmark.db", filepath.Join(source, "rawmark.db")}, {"vault", filepath.Join(source, "vault")}}, nil); err != nil { + t.Fatal(err) + } + return archive +} +func TestRecoveryCommitAndRollback(t *testing.T) { + for _, mode := range []string{"prepared", "applying", "invalid-source", "verifying"} { + t.Run(mode, func(t *testing.T) { + data := t.TempDir() + vault := filepath.Join(data, "vault") + oldArchive := fullArchive(t, t.TempDir(), "old") + oldStage := t.TempDir() + if _, err := Extract(context.Background(), oldArchive, oldStage); err != nil { + t.Fatal(err) + } + if err := CopyTree(filepath.Join(oldStage, "app"), data); err != nil { + t.Fatal(err) + } + if err := CopyTree(filepath.Join(oldStage, "vault"), vault); err != nil { + t.Fatal(err) + } + os.MkdirAll(filepath.Join(data, "backups"), 0700) + saved := filepath.Join(data, "backups/old.zip") + if err := copyFile(oldArchive, saved, 0600); err != nil { + t.Fatal(err) + } + upload := filepath.Join(data, ".maintenance/uploads/test/stage") + os.MkdirAll(upload, 0700) + newArchive := fullArchive(t, t.TempDir(), "new") + if _, err := Extract(context.Background(), newArchive, upload); err != nil { + t.Fatal(err) + } + state := mode + if mode == "invalid-source" { + state = "prepared" + writeTest(t, filepath.Join(upload, "app/rawmark.db"), "invalid SQLite") + } + if mode == "applying" || mode == "verifying" { + writeTest(t, filepath.Join(vault, "note.md"), "partial") + } + if err := WriteRecord(data, RestoreRecord{State: state, Stage: upload, Backup: saved}); err != nil { + t.Fatal(err) + } + err := Recover(data, vault) + expected := "old" + if mode == "prepared" { + if err != nil { + t.Fatal(err) + } + if pending, _ := Pending(data); !pending { + t.Fatal("验证启动前不应提交") + } + if err = FinishRecovery(data); err != nil { + t.Fatal(err) + } + expected = "new" + } else if err == nil { + t.Fatal("回退应保留失败原因") + } + body, err := os.ReadFile(filepath.Join(vault, "note.md")) + if err != nil || string(body) != expected { + t.Fatalf("恢复内容错误: %q %v", body, err) + } + if pending, err := Pending(data); err != nil || pending { + t.Fatalf("回退或提交后仍有记录: %v", err) + } + if err = CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil { + t.Fatal(err) + } + }) + } +} +func TestArchiveRejectsTraversalDuplicateLinksAndCorruption(t *testing.T) { + for _, mode := range []string{"traversal", "duplicate", "symlink", "checksum"} { + t.Run(mode, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "invalid.zip") + f, _ := os.Create(file) + z := zip.NewWriter(f) + switch mode { + case "traversal": + out, _ := z.Create("../outside") + out.Write([]byte("bad")) + case "duplicate": + z.Create("settings.json") + z.Create("settings.json") + case "symlink": + header := &zip.FileHeader{Name: "app/fonts/link"} + header.SetMode(os.ModeSymlink | 0777) + out, _ := z.CreateHeader(header) + out.Write([]byte("/etc/passwd")) + case "checksum": + out, _ := z.Create("settings.json") + out.Write([]byte("{}")) + manifest, _ := z.Create("manifest.json") + io.WriteString(manifest, `{"format":1,"kind":"settings","entries":[{"path":"settings.json","size":2,"sha256":"`+strings.Repeat("0", 64)+`"}]}`) + } + z.Close() + f.Close() + if _, err := Extract(context.Background(), file, t.TempDir()); err == nil { + t.Fatal("无效归档被接受") + } + }) + } +} +func TestSettingsInterruptedImportRestoresPreferencesAndResources(t *testing.T) { + data := t.TempDir() + database(t, filepath.Join(data, "rawmark.db")) + st, _ := store.OpenExisting(filepath.Join(data, "rawmark.db")) + st.SetSetting("app.settings", `{"fontSize":18}`) + source := t.TempDir() + writeTest(t, filepath.Join(source, "settings.json"), `{"app.settings":{"fontSize":18},"plugin.data.example":{"content":"old"}}`) + writeTest(t, filepath.Join(source, "fonts/original/font.woff2"), "old-font") + os.MkdirAll(filepath.Join(data, "backups"), 0700) + archive := filepath.Join(data, "backups/settings.zip") + if err := Create(context.Background(), archive, "settings", []Source{{"settings.json", filepath.Join(source, "settings.json")}, {"app/fonts", filepath.Join(source, "fonts")}}, nil); err != nil { + t.Fatal(err) + } + st.SetSetting("app.settings", `{"fontSize":24}`) + st.Close() + writeTest(t, filepath.Join(data, "fonts/new/font.woff2"), "new-font") + stage := filepath.Join(data, ".maintenance/uploads/test/stage") + os.MkdirAll(stage, 0700) + if err := WriteRecord(data, RestoreRecord{State: "applying", Kind: "settings", Stage: stage, Backup: archive}); err != nil { + t.Fatal(err) + } + if err := Recover(data, filepath.Join(data, "vault")); err == nil { + t.Fatal("应报告中断并回退") + } + st, _ = store.OpenExisting(filepath.Join(data, "rawmark.db")) + defer st.Close() + value, _, err := st.Setting("app.settings") + if err != nil || value != `{"fontSize":18}` { + t.Fatal("偏好未回退") + } + if body, err := os.ReadFile(filepath.Join(data, "fonts/original/font.woff2")); err != nil || string(body) != "old-font" { + t.Fatal("资源未回退") + } + if _, err := os.Stat(filepath.Join(data, "fonts/new")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("残留新资源") + } +} +func TestLogRotationIsBounded(t *testing.T) { + dir := t.TempDir() + log, err := OpenLog(dir) + if err != nil { + t.Fatal(err) + } + defer log.Close() + chunk := []byte(strings.Repeat("a", 6<<20)) + for i := 0; i < 5; i++ { + if _, err = log.Write(chunk); err != nil { + t.Fatal(err) + } + } + dest := t.TempDir() + if err = log.Snapshot(dest); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 3 { + t.Fatalf("轮转文件数错误: %d", len(entries)) + } + for _, entry := range entries { + info, _ := entry.Info() + if info.Size() > 10<<20 || info.Mode().Perm() != 0600 { + t.Fatalf("日志未限量或权限不符: %+v", info) + } + } +} diff --git a/internal/backup/log.go b/internal/backup/log.go new file mode 100644 index 0000000..d646c3b --- /dev/null +++ b/internal/backup/log.go @@ -0,0 +1,95 @@ +package backup + +import ( + "errors" + "io" + "os" + "path/filepath" + "sync" +) + +// LogWriter 保留三份各不超过 10 MiB 的应用日志,权限 0600。 +type LogWriter struct { + mu sync.Mutex + dir string + file *os.File + size int64 +} + +func OpenLog(dir string) (*LogWriter, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(dir, "rawmark.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + return &LogWriter{dir: dir, file: f, size: info.Size()}, nil +} +func (l *LogWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if len(p) > 10<<20 { + return 0, errors.New("单条日志超过大小上限") + } + if l.size+int64(len(p)) > 10<<20 { + if err := l.file.Close(); err != nil { + return 0, err + } + for i := 2; i >= 1; i-- { + dest := filepath.Join(l.dir, "rawmark.log.2") + src := filepath.Join(l.dir, "rawmark.log.1") + if i == 1 { + dest = src + src = filepath.Join(l.dir, "rawmark.log") + } + if err := os.Rename(src, dest); err != nil && !errors.Is(err, os.ErrNotExist) { + return 0, err + } + } + f, err := os.OpenFile(filepath.Join(l.dir, "rawmark.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return 0, err + } + l.file = f + l.size = 0 + } + n, err := l.file.Write(p) + l.size += int64(n) + return n, err +} +func (l *LogWriter) Close() error { l.mu.Lock(); defer l.mu.Unlock(); return l.file.Close() } + +// Snapshot 读取当前日志的稳定副本,避免导出与日志轮转竞争。 +func (l *LogWriter) Snapshot(dest string) error { + l.mu.Lock() + defer l.mu.Unlock() + if err := l.file.Sync(); err != nil { + return err + } + for _, name := range []string{"rawmark.log", "rawmark.log.1", "rawmark.log.2"} { + src, err := os.Open(filepath.Join(l.dir, name)) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + out, err := os.OpenFile(filepath.Join(dest, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + src.Close() + return err + } + _, err = io.Copy(out, src) + err = errors.Join(err, src.Close(), out.Close()) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go new file mode 100644 index 0000000..d153edf --- /dev/null +++ b/internal/backup/restore.go @@ -0,0 +1,373 @@ +package backup + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "rawmark/internal/store" +) + +// RestoreRecord 必须在关闭 HTTP、文件监听、索引和数据库之后执行。 +// prepared 尚未改动活动数据,applying 表示上次中断,必须先回退。 +type RestoreRecord struct { + State string `json:"state"` + Kind string `json:"kind,omitempty"` + Stage string `json:"stage"` + Backup string `json:"backup"` +} + +func recordPath(data string) string { return filepath.Join(data, ".maintenance", "restore.json") } +func WriteRecord(data string, record RestoreRecord) error { + bytes, err := json.Marshal(record) + if err != nil { + return err + } + dest := recordPath(data) + if err = os.MkdirAll(filepath.Dir(dest), 0700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(dest), "record-") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err = f.Chmod(0600); err == nil { + _, err = f.Write(bytes) + } + if err == nil { + err = f.Sync() + } + err = errors.Join(err, f.Close()) + if err != nil { + return err + } + if err = os.Rename(f.Name(), dest); err != nil { + return err + } + dir, err := os.Open(filepath.Dir(dest)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() +} +func Pending(data string) (bool, error) { + _, err := os.Stat(recordPath(data)) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return err == nil, err +} + +func CheckDatabase(file string) error { + // 先检查文件签名,Open 的 schema 初始化不能将错误文件伪装为新实例。 + f, err := os.Open(file) + if err != nil { + return err + } + header := make([]byte, 16) + _, err = io.ReadFull(f, header) + err = errors.Join(err, f.Close()) + if err != nil { + return err + } + if string(header) != "SQLite format 3\x00" { + return fmt.Errorf("备份数据库格式无效") + } + st, err := store.OpenExisting(file) + if err != nil { + return err + } + defer st.Close() + if err = st.CheckBackup(); err != nil { + return err + } + password, ok, err := st.Setting("auth.password") + if err != nil { + return err + } + parts := strings.Split(password, "$") + if !ok || len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" || parts[3] != "m=65536,t=3,p=4" { + return fmt.Errorf("备份密码格式不受支持") + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil || len(salt) != 16 { + return fmt.Errorf("备份密码盐无效") + } + key, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(key) != 32 { + return fmt.Errorf("备份密码校验值无效") + } + return nil +} + +// VaultExcluded 支持外置与嵌套仓库,避免将应用文件重复备份为笔记。 +func VaultExcluded(data, vault, rel string) bool { + for _, part := range strings.Split(filepath.ToSlash(rel), "/") { + if strings.HasPrefix(part, ".rawmark-tmp-") { + return true + } + } + full := filepath.Join(vault, filepath.FromSlash(rel)) + data, _ = filepath.Abs(data) + vault, _ = filepath.Abs(vault) + if data == vault { + top := strings.Split(filepath.ToSlash(rel), "/")[0] + switch top { + case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm", "fonts", "themes", "plugins", "logs", "temp", "backups", ".maintenance": + return true + } + } else if strings.HasPrefix(data, vault+string(filepath.Separator)) && (full == data || strings.HasPrefix(full, data+string(filepath.Separator))) { + return true + } + return false +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err = os.MkdirAll(filepath.Dir(dst), 0700); err != nil { + return err + } + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + return err + } + _, err = io.Copy(out, in) + if err == nil { + err = out.Sync() + } + return errors.Join(err, out.Close()) +} + +// CopyTree 不覆盖任何已有目标,也不允许跟随链接。 +func CopyTree(src, dest string) error { + return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dest, rel) + if d.IsDir() { + return os.MkdirAll(target, 0700) + } + info, err := d.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("不允许复制特殊文件") + } + if err = copyFile(p, target, 0600); err != nil { + return err + } + return os.Chtimes(target, info.ModTime(), info.ModTime()) + }) +} +func clearVault(data, root, dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, entry := range entries { + full := filepath.Join(dir, entry.Name()) + rel, err := filepath.Rel(root, full) + if err != nil { + return err + } + if VaultExcluded(data, root, rel) { + continue + } + // data 在某个祖先目录内时保留祖先,只清理其余子项。 + if entry.IsDir() && strings.HasPrefix(data, full+string(filepath.Separator)) { + if err = clearVault(data, root, full); err != nil { + return err + } + continue + } + if err = os.RemoveAll(full); err != nil { + return err + } + } + return nil +} +func apply(data, vault, stage string) error { + if err := clearVault(data, vault, vault); err != nil { + return err + } + if err := CopyTree(filepath.Join(stage, "vault"), vault); err != nil { + return err + } + for _, name := range []string{"rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm", "fonts", "themes", "plugins"} { + dest := filepath.Join(data, name) + if err := os.RemoveAll(dest); err != nil { + return err + } + source := filepath.Join(stage, "app", name) + info, err := os.Stat(source) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if info.IsDir() { + err = CopyTree(source, dest) + } else { + err = copyFile(source, dest, 0600) + } + if err != nil { + return err + } + } + if err := CheckDatabase(filepath.Join(data, "rawmark.db")); err != nil { + return err + } + st, err := store.OpenExisting(filepath.Join(data, "rawmark.db")) + if err != nil { + return err + } + err = st.ClearSessions() + return errors.Join(err, st.Close()) +} + +// Recover 在启动时完成预先确认的恢复;若中断或失败,使用操作前备份回退。 +func Recover(data, vault string) (err error) { + raw, err := os.ReadFile(recordPath(data)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var record RestoreRecord + if err = json.Unmarshal(raw, &record); err != nil { + return err + } + if record.State != "prepared" && record.State != "applying" && record.State != "verifying" { + return fmt.Errorf("恢复记录状态无效") + } + // 记录由本机生成;仍限制其读取范围,避免错误记录读取任意文件。 + if !strings.HasPrefix(record.Stage, filepath.Join(data, ".maintenance", "uploads")+string(filepath.Separator)) || !strings.HasPrefix(record.Backup, filepath.Join(data, "backups")+string(filepath.Separator)) { + return fmt.Errorf("恢复记录路径无效") + } + if record.Kind == "settings" { + return recoverSettings(data, record) + } + if record.State != "prepared" { + err = fmt.Errorf("上次实例恢复被中断") + } + if record.State == "prepared" { + record.State = "applying" + if err = WriteRecord(data, record); err != nil { + return err + } + if err = apply(data, vault, record.Stage); err == nil { + record.State = "verifying" + return WriteRecord(data, record) + } + } + stage, stageErr := os.MkdirTemp(filepath.Join(data, ".maintenance"), "rollback-") + if stageErr != nil { + return errors.Join(err, stageErr) + } + defer os.RemoveAll(stage) + _, restoreErr := Extract(context.Background(), record.Backup, stage) + if restoreErr == nil { + restoreErr = apply(data, vault, stage) + } + if restoreErr != nil { + return errors.Join(err, fmt.Errorf("实例回退失败: %w", restoreErr)) + } + if removeErr := os.Remove(recordPath(data)); removeErr != nil { + return errors.Join(err, removeErr) + } + if err != nil { + return fmt.Errorf("导入恢复失败,已回退原实例: %w", err) + } + return nil +} + +// FinishRecovery 在新实例的索引、监听与端口初始化成功后提交恢复。 +func FinishRecovery(data string) error { + raw, err := os.ReadFile(recordPath(data)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var record RestoreRecord + if err = json.Unmarshal(raw, &record); err != nil { + return err + } + if record.State != "verifying" { + return fmt.Errorf("恢复尚未完成初始化") + } + if err = os.RemoveAll(filepath.Dir(record.Stage)); err != nil { + return err + } + return os.Remove(recordPath(data)) +} + +func RemoveRecord(data string) error { return os.Remove(recordPath(data)) } +func recoverSettings(data string, record RestoreRecord) error { + stage, err := os.MkdirTemp(filepath.Join(data, ".maintenance"), "settings-rollback-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + if _, err = Extract(context.Background(), record.Backup, stage); err != nil { + return err + } + for _, kind := range []string{"fonts", "themes", "plugins"} { + target := filepath.Join(data, kind) + if err = os.RemoveAll(target); err != nil { + return err + } + source := filepath.Join(stage, "app", kind) + if _, err = os.Stat(source); errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if err = CopyTree(source, target); err != nil { + return err + } + } + raw, err := os.ReadFile(filepath.Join(stage, "settings.json")) + if err != nil { + return err + } + var values map[string]json.RawMessage + if err = json.Unmarshal(raw, &values); err != nil { + return err + } + st, err := store.OpenExisting(filepath.Join(data, "rawmark.db")) + if err != nil { + return err + } + err = st.ReplacePreferences(values) + err = errors.Join(err, st.Close()) + if err != nil { + return err + } + if err = os.Remove(recordPath(data)); err != nil { + return err + } + return fmt.Errorf("设置导入被中断,已回退原设置和资源") +} diff --git a/internal/store/backup.go b/internal/store/backup.go new file mode 100644 index 0000000..941d76e --- /dev/null +++ b/internal/store/backup.go @@ -0,0 +1,92 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// Snapshot 使用 SQLite 生成一致的独立数据库,不复制活动 WAL 文件。 +func (s *Store) Snapshot(ctx context.Context, dest string) error { + _, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, dest) + return err +} +func (s *Store) Preferences() (map[string]json.RawMessage, error) { + rows, err := s.db.Query(`SELECT key,value FROM settings WHERE key IN ('app.settings','plugins.enabled') OR key GLOB 'plugin.data.*' ORDER BY key`) + if err != nil { + return nil, err + } + defer rows.Close() + result := map[string]json.RawMessage{} + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return nil, err + } + if !json.Valid([]byte(value)) { + return nil, fmt.Errorf("设置 %s 格式无效", key) + } + result[key] = json.RawMessage(value) + } + return result, rows.Err() +} + +// ReplacePreferences 一次提交全部偏好,账户与会话不属于设置包。 +func (s *Store) ReplacePreferences(values map[string]json.RawMessage) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled') OR key GLOB 'plugin.data.*'`); err != nil { + return err + } + for key, value := range values { + if (key != "app.settings" && key != "plugins.enabled" && !strings.HasPrefix(key, "plugin.data.")) || !json.Valid(value) { + return fmt.Errorf("设置包含无效设置项") + } + if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, string(value)); err != nil { + return err + } + } + return tx.Commit() +} +func (s *Store) ResetPreferences() error { + _, err := s.db.Exec(`DELETE FROM settings WHERE key IN ('app.settings','plugins.enabled')`) + return err +} +func (s *Store) ClearSessions() error { _, err := s.db.Exec(`DELETE FROM sessions`); return err } + +// CheckBackup 确认数据库可读取且包含当前版本所需表;不会初始化或修复传入文件。 +func (s *Store) CheckBackup() error { + var result string + if err := s.db.QueryRow(`PRAGMA quick_check`).Scan(&result); err != nil { + return err + } + if result != "ok" { + return errors.New("备份数据库完整性检查失败") + } + for _, query := range []string{`SELECT key,value FROM settings LIMIT 0`, `SELECT token_hash,created_at,expires_at FROM sessions LIMIT 0`, `SELECT id,name,token_hash,scope,created_at,last_used_at FROM api_tokens LIMIT 0`} { + rows, err := s.db.Query(query) + if err != nil { + return err + } + if err = rows.Close(); err != nil { + return err + } + } + return nil +} + +// OpenExisting 只打开已有库;导入校验不得创建表或更改日志模式。 +func OpenExisting(path string) (*Store, error) { + db, err := sql.Open("sqlite", (&url.URL{Scheme: "file", Path: path, RawQuery: "mode=rw&_pragma=busy_timeout(5000)"}).String()) + if err != nil { + return nil, err + } + return &Store{db: db}, nil +}