package app import ( "archive/tar" "compress/gzip" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" ) // Backup is an offline operation: Open has already acquired the exclusive service lock. func (a *App) Backup(configPath, target string) error { abs, e := filepath.Abs(target) if e != nil { return e } data, e := filepath.Abs(a.cfg.DataDir) if e != nil { return e } if abs == data || strings.HasPrefix(abs, data+string(os.PathSeparator)) { return errors.New("备份目标必须在数据目录之外") } if e = a.db.Close(); e != nil { return e } a.db = nil out, e := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if e != nil { return e } success := false defer func() { out.Close() if !success { os.Remove(abs) } }() gz := gzip.NewWriter(out) tw := tar.NewWriter(gz) write := func(name string, f *os.File, st os.FileInfo) error { h := &tar.Header{Name: name, Mode: 0600, Size: st.Size(), ModTime: st.ModTime(), Typeflag: tar.TypeReg} if e := tw.WriteHeader(h); e != nil { return e } _, e := io.Copy(tw, f) return e } cf, e := os.Open(configPath) if e != nil { return e } st, e := cf.Stat() if e == nil { e = write("config.toml", cf, st) } cf.Close() if e != nil { return e } e = fs.WalkDir(a.root.FS(), ".", func(path string, d fs.DirEntry, e error) error { if e != nil { return e } if d.Type()&os.ModeSymlink != 0 { return fmt.Errorf("拒绝备份符号链接: %s", path) } if d.IsDir() || path == "service.lock" { return nil } if strings.HasPrefix(path, "tmp/") { return nil } st, e := d.Info() if e != nil { return e } if !st.Mode().IsRegular() { return errors.New("数据目录包含特殊文件") } f, e := a.root.Open(path) if e != nil { return e } defer f.Close() return write("var/"+path, f, st) }) if e != nil { return e } if e = tw.Close(); e != nil { return e } if e = gz.Close(); e != nil { return e } if e = out.Sync(); e != nil { return e } success = true return nil } func Restore(c Config, source string) error { if e := c.Validate(); e != nil { return e } if e := os.MkdirAll(c.DataDir, 0700); e != nil { return e } entries, e := os.ReadDir(c.DataDir) if e != nil { return e } if len(entries) != 0 { return errors.New("恢复数据目录必须为空") } root, e := os.OpenRoot(c.DataDir) if e != nil { return e } defer root.Close() if e = root.WriteFile("restore.incomplete", []byte("restoring"), 0600); e != nil { return e } input, e := os.Open(source) if e != nil { return e } defer input.Close() gz, e := gzip.NewReader(input) if e != nil { return e } defer gz.Close() tr := tar.NewReader(gz) var total int64 var count int for { h, e := tr.Next() if errors.Is(e, io.EOF) { break } if e != nil { return e } if h.Typeflag != tar.TypeReg || !safeRelative(h.Name) { return errors.New("备份包含非法路径或文件类型") } count++ if count > c.MaxRecords+32 { return errors.New("备份文件数量超出恢复限制") } name := "" if h.Name == "config.toml" { name = "restored-config.toml" } else if strings.HasPrefix(h.Name, "var/") { name = strings.TrimPrefix(h.Name, "var/") } else { return errors.New("未知备份路径") } if !safeRelative(name) || h.Size < 0 || h.Size > c.DataBudget || total > c.DataBudget-h.Size { return errors.New("备份超过恢复容量") } total += h.Size if e = root.MkdirAll(filepath.Dir(name), 0700); e != nil { return e } f, e := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if e != nil { return e } _, e = io.CopyN(f, tr, h.Size) if e == nil { e = f.Sync() } ce := f.Close() if e == nil { e = ce } if e != nil { return e } } if _, e = io.Copy(io.Discard, gz); e != nil { return e } // Invalidate before Open runs lifecycle cleanup, leaving payloads retained for admin review. dbPath := filepath.Join(c.DataDir, "db", "bluebird.db") if _, e = os.Stat(dbPath); e != nil { return errors.New("备份缺少数据库") } a, e := OpenForRestore(c) if e != nil { return e } defer a.Close() if e = a.Check(); e != nil { return e } if e = a.InvalidateRestored(); e != nil { return e } if e = root.Remove("restore.incomplete"); e != nil { return e } return syncDir(root, ".") }