// 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 }