Scert/backend/internal/cli/backup_restore.go

781 lines
25 KiB
Go
Raw Permalink 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 cli
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
)
const backupManifestVersion = 1
type backupManifest struct {
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
Files []backupManifestFile `json:"files"`
}
type backupManifestFile struct {
Path string `json:"path"`
Mode uint32 `json:"mode"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
func createBackup(ctx context.Context, cfg config.Config, configPath, output string) error {
return createBackupWithPostgresTools(ctx, cfg, configPath, output, loadPostgresTools)
}
func createBackupWithPostgresTools(ctx context.Context, cfg config.Config, configPath, output string, factory postgresToolsFactory) error {
if err := validateBackupDriver(cfg.Database.Driver); err != nil {
return err
}
var tools *postgresTools
var postgresConnection config.DatabaseConfig
if cfg.Database.Driver == "postgres" {
var err error
tools, err = factory(ctx)
if err != nil {
return err
}
postgresConnection, err = resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config)
if err != nil {
return err
}
if _, _, err := postgresToolConnection(postgresConnection); err != nil {
return err
}
}
output = filepath.Clean(output)
if err := ensureBackupDestination(output); err != nil {
return err
}
parent := filepath.Dir(output)
if err := os.MkdirAll(parent, 0o700); err != nil {
return fmt.Errorf("创建备份父目录失败:%w", err)
}
parentInfo, err := os.Lstat(parent)
if err != nil {
return fmt.Errorf("检查备份父目录失败:%w", err)
}
if parentInfo.Mode()&os.ModeSymlink != 0 || !parentInfo.IsDir() {
return errors.New("备份父路径必须是普通目录")
}
staging, err := os.MkdirTemp(parent, ".scert-backup-")
if err != nil {
return fmt.Errorf("创建备份暂存目录失败:%w", err)
}
completed := false
defer func() {
if !completed {
_ = os.RemoveAll(staging)
}
}()
if err := os.Chmod(staging, 0o700); err != nil {
return fmt.Errorf("设置备份暂存目录权限失败:%w", err)
}
if err := copyFileExclusive(configPath, filepath.Join(staging, "scert.json"), 0o600); err != nil {
return err
}
if err := validateBackupSecretStore(ctx, cfg.Paths.Config, cfg); err != nil {
return err
}
for _, name := range []string{"master.key", "secrets.json"} {
source := filepath.Join(cfg.Paths.Config, name)
info, statErr := os.Lstat(source)
if statErr == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("敏感配置文件不是普通文件:%s", name)
}
if err := copyFileExclusive(source, filepath.Join(staging, name), 0o600); err != nil {
return err
}
continue
}
if !errors.Is(statErr, os.ErrNotExist) {
return fmt.Errorf("检查敏感配置文件失败:%w", statErr)
}
if cfg.SetupReady {
return fmt.Errorf("已初始化配置缺少敏感配置文件:%s", name)
}
}
if cfg.Database.Driver == "sqlite" {
_, memory, err := sqliteDatabasePath(cfg.Database.DSN)
if err != nil {
return err
}
if !memory {
if err := backupSQLiteDatabase(ctx, cfg, filepath.Join(staging, "scert.db")); err != nil {
return err
}
}
}
if cfg.Database.Driver == "mysql" {
if err := backupMySQL(ctx, cfg, filepath.Join(staging, "scert.sql")); err != nil {
return err
}
}
if cfg.Database.Driver == "postgres" {
if err := tools.backup(ctx, postgresConnection, filepath.Join(staging, "scert.pgdump")); err != nil {
return err
}
}
if info, err := os.Lstat(cfg.Paths.Certificate); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("证书根目录必须是普通目录,不能使用符号链接")
}
if pathsOverlap(cfg.Paths.Certificate, output) {
return errors.New("备份目录不能位于证书根目录内,也不能包含证书根目录")
}
if err := copyDirectoryExclusive(cfg.Paths.Certificate, filepath.Join(staging, "certificates")); err != nil {
return fmt.Errorf("备份证书目录失败:%w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("检查证书根目录失败:%w", err)
}
if err := ctx.Err(); err != nil {
return err
}
if err := writeBackupManifest(staging); err != nil {
return err
}
if err := syncDirectory(staging); err != nil {
return fmt.Errorf("同步备份暂存目录失败:%w", err)
}
if err := ctx.Err(); err != nil {
return err
}
if err := os.Rename(staging, output); err != nil {
return fmt.Errorf("发布备份目录失败:%w", err)
}
if err := syncDirectory(parent); err != nil {
return fmt.Errorf("同步备份父目录失败:%w", err)
}
completed = true
return nil
}
func backupSQLiteDatabase(ctx context.Context, cfg config.Config, output string) error {
if _, memory, err := sqliteDatabasePath(cfg.Database.DSN); err != nil {
return err
} else if memory {
return errors.New("内存 SQLite 数据库不能创建文件备份")
}
db, err := database.Open(ctx, cfg.Database)
if err != nil {
return fmt.Errorf("打开 SQLite 数据库失败:%w", err)
}
defer db.Close()
absOutput, err := filepath.Abs(output)
if err != nil {
return fmt.Errorf("解析 SQLite 备份路径失败:%w", err)
}
if _, err := os.Lstat(absOutput); err == nil {
return errors.New("SQLite 备份文件已存在")
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("检查 SQLite 备份文件失败:%w", err)
}
statement := "VACUUM INTO '" + strings.ReplaceAll(filepath.ToSlash(absOutput), "'", "''") + "'"
if _, err := db.DB.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("创建 SQLite 一致性快照失败:%w", err)
}
if err := os.Chmod(absOutput, 0o600); err != nil {
return fmt.Errorf("设置 SQLite 备份权限失败:%w", err)
}
if err := validateSQLiteDatabase(ctx, absOutput); err != nil {
return err
}
return nil
}
func validateSQLiteDatabase(ctx context.Context, path string) error {
if !isRegularFile(path) {
return errors.New("SQLite 备份不是普通文件")
}
db, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("打开 SQLite 备份失败:%w", err)
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("检查 SQLite 备份完整性失败:%w", err)
}
if !strings.EqualFold(strings.TrimSpace(result), "ok") {
return fmt.Errorf("SQLite 备份完整性检查失败:%s", result)
}
return nil
}
func sqliteDatabasePath(dsn string) (string, bool, error) {
dsn = strings.TrimSpace(dsn)
if dsn == ":memory:" || strings.HasPrefix(dsn, "file::memory:") {
return "", true, nil
}
if strings.HasPrefix(dsn, "file:") {
dsn = strings.TrimPrefix(strings.SplitN(dsn, "?", 2)[0], "file:")
}
if dsn == "" {
return "", false, errors.New("SQLite 数据库路径不能为空")
}
return filepath.Clean(filepath.FromSlash(dsn)), false, nil
}
func validateRestoreCompatibility(current, backup config.Config) error {
if err := validateBackupDriver(backup.Database.Driver); err != nil {
return err
}
if current.Database.Driver != backup.Database.Driver {
return fmt.Errorf("备份数据库类型(%s)与当前配置数据库类型(%s)不一致,拒绝恢复", backup.Database.Driver, current.Database.Driver)
}
for name, values := range map[string][2]string{
"配置目录": {current.Paths.Config, backup.Paths.Config},
"数据目录": {current.Paths.Data, backup.Paths.Data},
"证书目录": {current.Paths.Certificate, backup.Paths.Certificate},
"备份目录": {current.Paths.Backup, backup.Paths.Backup},
} {
if !samePath(values[0], values[1]) {
return fmt.Errorf("备份%s与当前配置不一致,拒绝跨目录恢复", name)
}
}
if backup.Database.Driver == "sqlite" {
currentPath, currentMemory, err := sqliteDatabasePath(current.Database.DSN)
if err != nil {
return fmt.Errorf("解析当前 SQLite 数据库路径失败:%w", err)
}
backupPath, backupMemory, err := sqliteDatabasePath(backup.Database.DSN)
if err != nil {
return fmt.Errorf("解析备份 SQLite 数据库路径失败:%w", err)
}
if currentMemory != backupMemory || (!currentMemory && !samePath(currentPath, backupPath)) {
return errors.New("备份 SQLite 数据库路径与当前配置不一致,拒绝跨数据库恢复")
}
}
if backup.Database.Driver == "mysql" && strings.TrimSpace(current.Database.DSN) != strings.TrimSpace(backup.Database.DSN) {
return errors.New("备份 MySQL 连接配置与当前配置不一致,拒绝跨数据库恢复")
}
if backup.Database.Driver == "postgres" && strings.TrimSpace(current.Database.DSN) != strings.TrimSpace(backup.Database.DSN) {
return errors.New("备份 PostgreSQL 连接配置与当前配置不一致,拒绝跨数据库恢复")
}
return nil
}
func validateBackupSecretStore(ctx context.Context, directory string, cfg config.Config) error {
masterPath := filepath.Join(directory, "master.key")
secretsPath := filepath.Join(directory, "secrets.json")
masterExists := isRegularFile(masterPath)
secretsExists := isRegularFile(secretsPath)
if !masterExists {
if secretsExists {
return errors.New("敏感配置包含 secrets.json,但缺少 master.key")
}
if cfg.SetupReady || strings.TrimSpace(cfg.Database.CredentialRef) != "" {
return errors.New("已初始化配置缺少 master.key")
}
return nil
}
store, err := secretstore.Open(directory)
if err != nil {
return fmt.Errorf("校验敏感配置主密钥失败:%w", err)
}
metadata, err := store.List(ctx, "")
if err != nil {
return fmt.Errorf("校验敏感配置文件失败:%w", err)
}
available := make(map[string]struct{}, len(metadata))
for _, item := range metadata {
if _, err := store.Get(ctx, item.Reference); err != nil {
return fmt.Errorf("校验敏感配置引用失败:%s:%w", item.Reference, err)
}
available[item.Reference] = struct{}{}
}
for _, reference := range []string{cfg.Database.CredentialRef, cfg.DNS.CredentialRef, cfg.Notification.HTTPCredentialRef, cfg.Notification.SMTPCredentialRef} {
reference = strings.TrimSpace(reference)
if reference == "" {
continue
}
if _, exists := available[reference]; !exists {
return fmt.Errorf("敏感配置缺少配置引用:%s", reference)
}
}
if cfg.SetupReady && !secretsExists {
return errors.New("已初始化配置缺少 secrets.json")
}
return nil
}
func restoreAdditionalAssets(cfg config.Config, input string) error {
timestamp := time.Now().UTC().Format("20060102-150405.000000000")
rollbacks := make([]func() error, 0, 3)
rollbackAll := func() error {
var rollbackErr error
for index := len(rollbacks) - 1; index >= 0; index-- {
rollbackErr = errors.Join(rollbackErr, rollbacks[index]())
}
return rollbackErr
}
for _, name := range []string{"master.key", "secrets.json"} {
source := filepath.Join(input, name)
target := filepath.Join(cfg.Paths.Config, name)
info, err := os.Lstat(source)
if err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.Join(fmt.Errorf("备份敏感配置文件不是普通文件:%s", name), rollbackAll())
}
rollback, restoreErr := restoreFileWithRollback(source, target, 0o600, timestamp)
if restoreErr != nil {
return errors.Join(restoreErr, rollbackAll())
}
rollbacks = append(rollbacks, rollback)
continue
}
if !errors.Is(err, os.ErrNotExist) {
return errors.Join(fmt.Errorf("检查备份敏感配置文件失败:%w", err), rollbackAll())
}
rollback, removeErr := removeFileWithRollback(target, 0o600, timestamp)
if removeErr != nil {
return errors.Join(removeErr, rollbackAll())
}
rollbacks = append(rollbacks, rollback)
}
certificateSource := filepath.Join(input, "certificates")
if info, err := os.Lstat(certificateSource); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.Join(errors.New("备份证书路径必须是普通目录"), rollbackAll())
}
rollback, err := restoreDirectoryWithRollback(certificateSource, cfg.Paths.Certificate, timestamp)
if err != nil {
return errors.Join(err, rollbackAll())
}
rollbacks = append(rollbacks, rollback)
} else if errors.Is(err, os.ErrNotExist) {
rollback, removeErr := removeDirectoryWithRollback(cfg.Paths.Certificate, timestamp)
if removeErr != nil {
return errors.Join(removeErr, rollbackAll())
}
rollbacks = append(rollbacks, rollback)
} else {
return errors.Join(fmt.Errorf("检查备份证书目录失败:%w", err), rollbackAll())
}
return nil
}
func restoreFileWithRollback(source, target string, mode os.FileMode, timestamp string) (func() error, error) {
if !isRegularFile(source) {
return nil, errors.New("恢复源文件必须是普通文件")
}
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return nil, fmt.Errorf("创建恢复目标目录失败:%w", err)
}
backupPath := ""
if info, err := os.Lstat(target); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.New("恢复目标文件不是普通文件")
}
backupPath = target + ".before-restore-" + timestamp
if err := copyFileExclusive(target, backupPath, mode); err != nil {
return nil, err
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("检查恢复目标文件失败:%w", err)
}
if err := copyFile(source, target, mode); err != nil {
if backupPath != "" {
_ = copyFile(backupPath, target, mode)
}
return nil, err
}
return func() error {
if backupPath != "" {
return copyFile(backupPath, target, mode)
}
return os.Remove(target)
}, nil
}
func removeFileWithRollback(target string, mode os.FileMode, timestamp string) (func() error, error) {
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return func() error { return nil }, nil
}
if err != nil {
return nil, fmt.Errorf("检查恢复目标文件失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, errors.New("恢复目标文件不是普通文件")
}
backupPath := target + ".before-restore-" + timestamp
if err := copyFileExclusive(target, backupPath, mode); err != nil {
return nil, err
}
if err := os.Remove(target); err != nil {
return nil, fmt.Errorf("移除备份中不存在的当前文件失败:%w", err)
}
return func() error { return copyFile(backupPath, target, mode) }, nil
}
func removeDirectoryWithRollback(target, timestamp string) (func() error, error) {
info, err := os.Lstat(target)
if errors.Is(err, os.ErrNotExist) {
return func() error { return nil }, nil
}
if err != nil {
return nil, fmt.Errorf("检查证书恢复目标失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return nil, errors.New("证书恢复目标必须是普通目录")
}
return restoreDirectoryContentsWithRollback("", target, timestamp)
}
func restoreDirectoryWithRollback(source, target, timestamp string) (func() error, error) {
if pathsOverlap(source, target) {
return nil, errors.New("证书恢复源目录和目标目录不能互相包含")
}
return restoreDirectoryContentsWithRollback(source, target, timestamp)
}
func restoreDirectoryContentsWithRollback(source, target, timestamp string) (func() error, error) {
createdTarget := false
if info, err := os.Lstat(target); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return nil, errors.New("证书恢复目标必须是普通目录")
}
} else if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return nil, fmt.Errorf("创建证书恢复父目录失败:%w", err)
}
if err := os.Mkdir(target, 0o750); err != nil {
return nil, fmt.Errorf("创建证书恢复父目录失败:%w", err)
}
createdTarget = true
} else {
return nil, fmt.Errorf("检查证书恢复目标失败:%w", err)
}
transaction, err := os.MkdirTemp(target, ".scert-restore-")
if err != nil {
return nil, fmt.Errorf("保留现有证书目录失败:%w", err)
}
oldDirectory := filepath.Join(transaction, "old")
if err := os.Mkdir(oldDirectory, 0o700); err != nil {
_ = os.RemoveAll(transaction)
return nil, fmt.Errorf("保留现有证书目录失败:%w", err)
}
rollback := func() error {
var rollbackErr error
rollbackErr = errors.Join(rollbackErr, clearDirectoryChildren(target, filepath.Base(transaction)))
rollbackErr = errors.Join(rollbackErr, moveDirectoryChildren(oldDirectory, target))
if createdTarget {
if removeErr := os.Remove(target); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
rollbackErr = errors.Join(rollbackErr, removeErr)
}
}
rollbackErr = errors.Join(rollbackErr, os.RemoveAll(transaction))
return rollbackErr
}
if err := moveDirectoryChildren(target, oldDirectory, filepath.Base(transaction)); err != nil {
return nil, errors.Join(fmt.Errorf("保留现有证书目录失败:%w", err), rollback())
}
if source != "" {
newDirectory := filepath.Join(transaction, "new")
if err := copyDirectoryExclusive(source, newDirectory); err != nil {
return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback())
}
if err := moveDirectoryChildren(newDirectory, target); err != nil {
return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback())
}
}
if err := os.RemoveAll(transaction); err != nil {
return nil, errors.Join(fmt.Errorf("恢复证书目录失败:%w", err), rollback())
}
return func() error { return nil }, nil
}
func moveDirectoryChildren(source, target string, excluded ...string) error {
entries, err := os.ReadDir(source)
if err != nil {
return err
}
excludedNames := make(map[string]struct{}, len(excluded))
for _, name := range excluded {
excludedNames[name] = struct{}{}
}
for _, entry := range entries {
if _, skip := excludedNames[entry.Name()]; skip {
continue
}
if err := os.Rename(filepath.Join(source, entry.Name()), filepath.Join(target, entry.Name())); err != nil {
return err
}
}
return nil
}
func clearDirectoryChildren(directory, excluded string) error {
entries, err := os.ReadDir(directory)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
var clearErr error
for _, entry := range entries {
if entry.Name() == excluded {
continue
}
clearErr = errors.Join(clearErr, os.RemoveAll(filepath.Join(directory, entry.Name())))
}
return clearErr
}
func copyDirectoryExclusive(source, target string) error {
if pathsOverlap(source, target) {
return errors.New("复制源目录和目标目录不能互相包含")
}
rootInfo, err := os.Lstat(source)
if err != nil {
return fmt.Errorf("检查源目录失败:%w", err)
}
if rootInfo.Mode()&os.ModeSymlink != 0 || !rootInfo.IsDir() {
return errors.New("复制源路径必须是普通目录")
}
if _, err := os.Lstat(target); err == nil {
return errors.New("复制目标目录已存在")
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("检查复制目标目录失败:%w", err)
}
return filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
info, err := entry.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("目录中不允许包含符号链接:%s", path)
}
relative, err := filepath.Rel(source, path)
if err != nil {
return err
}
destination := filepath.Join(target, relative)
if entry.IsDir() {
mode := info.Mode().Perm()
if mode == 0 {
mode = 0o750
}
return os.Mkdir(destination, mode)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("目录中包含不支持的文件类型:%s", path)
}
return copyFileExclusive(path, destination, info.Mode().Perm())
})
}
func pathsOverlap(left, right string) bool {
leftAbs, leftErr := filepath.Abs(filepath.Clean(left))
rightAbs, rightErr := filepath.Abs(filepath.Clean(right))
if leftErr != nil || rightErr != nil {
return true
}
return pathContains(leftAbs, rightAbs) || pathContains(rightAbs, leftAbs)
}
func pathContains(parent, child string) bool {
relative, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return relative == "." || (relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)))
}
func writeBackupManifest(directory string) error {
manifest, err := buildBackupManifest(directory)
if err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("生成备份清单失败:%w", err)
}
data = append(data, '\n')
path := filepath.Join(directory, "manifest.json")
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("创建备份清单失败:%w", err)
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return fmt.Errorf("写入备份清单失败:%w", err)
}
if err := file.Sync(); err != nil {
_ = file.Close()
return fmt.Errorf("同步备份清单失败:%w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭备份清单失败:%w", err)
}
return nil
}
func syncDirectory(path string) error {
if runtime.GOOS == "windows" {
return nil
}
directory, err := os.Open(path)
if err != nil {
return err
}
defer directory.Close()
return directory.Sync()
}
func buildBackupManifest(directory string) (backupManifest, error) {
manifest := backupManifest{Version: backupManifestVersion, CreatedAt: time.Now().UTC()}
err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == directory || entry.IsDir() {
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("备份中不允许包含符号链接:%s", path)
}
info, err := entry.Info()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("备份中包含不支持的文件类型:%s", path)
}
relative, err := filepath.Rel(directory, path)
if err != nil {
return err
}
digest, err := fileSHA256(path)
if err != nil {
return err
}
manifest.Files = append(manifest.Files, backupManifestFile{
Path: filepath.ToSlash(relative), Mode: uint32(info.Mode().Perm()), Size: info.Size(), SHA256: digest,
})
return nil
})
if err != nil {
return backupManifest{}, fmt.Errorf("生成备份文件清单失败:%w", err)
}
sort.Slice(manifest.Files, func(i, j int) bool { return manifest.Files[i].Path < manifest.Files[j].Path })
return manifest, nil
}
func validateBackupManifest(directory string) error {
path := filepath.Join(directory, "manifest.json")
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return errors.New("备份目录缺少 manifest.json,拒绝恢复未校验的备份")
}
if err != nil {
return fmt.Errorf("检查备份清单失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return errors.New("备份清单必须是普通文件")
}
if info.Size() > 8<<20 {
return errors.New("备份清单超过 8 MiB 安全上限")
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("读取备份清单失败:%w", err)
}
var expected backupManifest
if err := json.Unmarshal(data, &expected); err != nil {
return fmt.Errorf("解析备份清单失败:%w", err)
}
if expected.Version != backupManifestVersion {
return fmt.Errorf("不支持的备份清单版本:%d", expected.Version)
}
if expected.CreatedAt.IsZero() {
return errors.New("备份清单缺少创建时间")
}
if len(expected.Files) == 0 {
return errors.New("备份清单不包含任何文件")
}
actual, err := buildBackupManifestExcludingManifest(directory)
if err != nil {
return err
}
if len(actual.Files) != len(expected.Files) {
return errors.New("备份文件数量与清单不一致")
}
for index := range expected.Files {
left, right := expected.Files[index], actual.Files[index]
if left.Path != right.Path || left.Mode != right.Mode || left.Size != right.Size || !strings.EqualFold(left.SHA256, right.SHA256) {
return fmt.Errorf("备份文件校验失败:%s", left.Path)
}
}
return nil
}
func buildBackupManifestExcludingManifest(directory string) (backupManifest, error) {
manifest, err := buildBackupManifest(directory)
if err != nil {
return backupManifest{}, err
}
filtered := manifest.Files[:0]
for _, item := range manifest.Files {
if item.Path != "manifest.json" {
filtered = append(filtered, item)
}
}
manifest.Files = filtered
return manifest, nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func removeIfExists(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}