Files
RawMark/internal/backup/archive.go
T

313 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package backup 处理 RawMark 自有的实例与设置归档;普通 Markdown 导出由 vault 负责。
package backup
import (
"archive/zip"
"compress/flate"
"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
// ErrInvalidArchive 表示归档内容不满足备份契约,不包含磁盘或文件系统故障。
var ErrInvalidArchive = errors.New("备份内容无效")
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 errors.Is(err, zip.ErrInsecurePath) {
z.Close()
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
}
if errors.Is(err, zip.ErrFormat) {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
}
if err != nil {
return manifest, err
}
defer z.Close()
if len(z.File) > MaxEntries+1 {
return manifest, fmt.Errorf("%w:备份文件数超过上限", ErrInvalidArchive)
}
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("%w:归档路径无效或重复", ErrInvalidArchive)
}
if !f.Mode().IsRegular() && !f.Mode().IsDir() {
return manifest, fmt.Errorf("%w:归档不允许符号链接或特殊文件", ErrInvalidArchive)
}
entries[name] = f
}
m := entries["manifest.json"]
if m == nil || m.UncompressedSize64 > 32<<20 {
return manifest, fmt.Errorf("%w:缺少有效备份清单", ErrInvalidArchive)
}
r, err := m.Open()
if err != nil {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, err)
}
err = json.NewDecoder(io.LimitReader(r, 32<<20)).Decode(&manifest)
if err != nil {
err = fmt.Errorf("%w:%w", ErrInvalidArchive, err)
}
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("%w:备份版本、类型或清单无效", ErrInvalidArchive)
}
var expanded int64
for _, e := range manifest.Entries {
if e.Size < 0 || e.Size > MaxExpandedBytes-expanded {
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive)
}
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("磁盘空间不足:%w", syscall.ENOSPC)
}
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("%w:备份清单与文件不符", ErrInvalidArchive)
}
seen[e.Path] = true
total += e.Size
if total > MaxExpandedBytes {
return manifest, fmt.Errorf("%w:备份展开大小超过 8 GiB", ErrInvalidArchive)
}
target := filepath.Join(dest, filepath.FromSlash(e.Path))
if e.Dir {
if e.Size != 0 {
return manifest, fmt.Errorf("%w:目录大小无效", ErrInvalidArchive)
}
if err := os.MkdirAll(target, 0700); err != nil {
return manifest, err
}
continue
}
if len(e.SHA256) != 64 {
return manifest, fmt.Errorf("%w:文件校验值无效", ErrInvalidArchive)
}
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
return manifest, err
}
in, err := f.Open()
if err != nil {
return manifest, fmt.Errorf("%w:%w", ErrInvalidArchive, 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))
var corruptDeflate flate.CorruptInputError
if errors.Is(copyErr, zip.ErrChecksum) || errors.Is(copyErr, zip.ErrFormat) || errors.Is(copyErr, io.ErrUnexpectedEOF) || errors.As(copyErr, &corruptDeflate) {
copyErr = fmt.Errorf("%w:%w", ErrInvalidArchive, copyErr)
}
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("%w:文件校验失败: %s", ErrInvalidArchive, 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"] || entries["app/rawmark.db"].FileInfo().IsDir() || !entries["vault"].FileInfo().IsDir()) {
return manifest, fmt.Errorf("%w:实例备份缺少数据库或笔记库", ErrInvalidArchive)
}
if manifest.Kind == "settings" && (!seen["settings.json"] || entries["settings.json"].FileInfo().IsDir()) {
return manifest, fmt.Errorf("%w:设置备份缺少设置", ErrInvalidArchive)
}
return manifest, nil
}