package backend import ( "crypto/sha256" "database/sql" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "log" "os" "path/filepath" "runtime" "sort" "strings" "time" ) const snapshotFormatVersion = 1 type SnapshotManifest struct { FormatVersion int `json:"format_version"` CreatedAt string `json:"created_at"` DatabasePath string `json:"database_path"` DatabaseSHA256 string `json:"database_sha256"` IndexSHA256 string `json:"index_sha256"` IndexRecords int64 `json:"index_records"` PreciseCopied int64 `json:"precise_copied"` PendingMbox int64 `json:"pending_mbox"` PendingTarget int64 `json:"pending_target"` InvalidStageOrder int64 `json:"invalid_stage_order"` MissingArchiveRecords int64 `json:"missing_archive_records"` Files []SnapshotFile `json:"files"` } type SnapshotFile struct { Path string `json:"path"` Bytes int64 `json:"bytes"` SHA256 string `json:"sha256"` AccountID int64 `json:"account_id"` Account string `json:"account"` Folder string `json:"folder"` IndexRecords int64 `json:"index_records"` } type snapshotWatermark struct { AccountID int64 Account string MboxDir string Folder string Bytes int64 IndexRecords int64 SourcePath string RelativePath string } // CreateSnapshot creates a self-consistent DB + mbox-prefix snapshot without // stopping append-only archive writers. The DB snapshot is taken first; every // copied mbox prefix is then bounded exclusively by offsets from that snapshot. func CreateSnapshot(target string) error { return createSnapshot(target, nil) } func createSnapshot(target string, afterDatabaseSnapshot func() error) (retErr error) { if DB == nil { return fmt.Errorf("snapshot: database is not connected") } target = strings.TrimSpace(target) if target == "" { return fmt.Errorf("snapshot: target directory is empty") } targetAbs, err := filepath.Abs(target) if err != nil { return fmt.Errorf("snapshot target: %w", err) } if _, err := os.Stat(targetAbs); err == nil { return fmt.Errorf("snapshot target already exists: %s", targetAbs) } else if !os.IsNotExist(err) { return fmt.Errorf("snapshot target stat: %w", err) } if err := os.MkdirAll(filepath.Dir(targetAbs), 0o700); err != nil { return fmt.Errorf("snapshot target parent: %w", err) } partial := fmt.Sprintf("%s.partial-%d-%d", targetAbs, os.Getpid(), time.Now().UnixNano()) if err := os.Mkdir(partial, 0o700); err != nil { return fmt.Errorf("snapshot staging directory: %w", err) } complete := false defer func() { if !complete { log.Printf("snapshot incomplete; staging data retained at %s", partial) if retErr != nil { retErr = fmt.Errorf("%w (incomplete snapshot retained at %s)", retErr, partial) } } }() databaseRel := filepath.ToSlash(filepath.Join("data", "mail-graveyard.db")) databasePath := filepath.Join(partial, filepath.FromSlash(databaseRel)) if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { return fmt.Errorf("snapshot database directory: %w", err) } if _, err := DB.Exec(`VACUUM INTO ?`, databasePath); err != nil { return fmt.Errorf("snapshot database VACUUM INTO: %w", err) } if err := chmodPrivateFile(databasePath); err != nil { return fmt.Errorf("snapshot database permissions: %w", err) } if err := syncFile(databasePath); err != nil { return fmt.Errorf("snapshot database sync: %w", err) } if afterDatabaseSnapshot != nil { if err := afterDatabaseSnapshot(); err != nil { return fmt.Errorf("snapshot test hook: %w", err) } } snapshotDB, err := openSnapshotDatabase(databasePath) if err != nil { return err } watermarks, err := collectSnapshotWatermarks(snapshotDB, Cfg.MboxRoot) if err != nil { _ = snapshotDB.Close() return err } stats, err := snapshotDatabaseStats(snapshotDB) if err == nil { stats.IndexSHA256, err = indexDigest(snapshotDB) } if closeErr := snapshotDB.Close(); err == nil && closeErr != nil { err = closeErr } if err != nil { return err } if stats.MissingArchiveRecords != 0 { return fmt.Errorf("snapshot refused: %d mbox_done identities have no archive index", stats.MissingArchiveRecords) } if stats.InvalidStageOrder != 0 { return fmt.Errorf("snapshot refused: %d precise identities have target_done=1 before mbox_done", stats.InvalidStageOrder) } var watermarkRecords int64 for _, watermark := range watermarks { watermarkRecords += watermark.IndexRecords } if watermarkRecords != stats.IndexRecords { return fmt.Errorf("snapshot refused: indexed records=%d but mapped mbox records=%d", stats.IndexRecords, watermarkRecords) } manifest := SnapshotManifest{ FormatVersion: snapshotFormatVersion, CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), DatabasePath: databaseRel, IndexSHA256: stats.IndexSHA256, IndexRecords: stats.IndexRecords, PreciseCopied: stats.PreciseCopied, PendingMbox: stats.PendingMbox, PendingTarget: stats.PendingTarget, InvalidStageOrder: stats.InvalidStageOrder, MissingArchiveRecords: stats.MissingArchiveRecords, Files: make([]SnapshotFile, 0, len(watermarks)), } manifest.DatabaseSHA256, err = hashFile(databasePath) if err != nil { return fmt.Errorf("snapshot database hash: %w", err) } for _, watermark := range watermarks { destinationRel := filepath.ToSlash(filepath.Join("backup", watermark.RelativePath)) destination := filepath.Join(partial, filepath.FromSlash(destinationRel)) hash, err := copyFilePrefix(watermark.SourcePath, destination, watermark.Bytes) if err != nil { return fmt.Errorf("snapshot mbox %s: %w", destinationRel, err) } manifest.Files = append(manifest.Files, SnapshotFile{ Path: destinationRel, Bytes: watermark.Bytes, SHA256: hash, AccountID: watermark.AccountID, Account: watermark.Account, Folder: watermark.Folder, IndexRecords: watermark.IndexRecords, }) } sortSnapshotFiles(manifest.Files) manifestPath := filepath.Join(partial, "snapshot-manifest.json") manifestJSON, err := json.MarshalIndent(manifest, "", " ") if err != nil { return fmt.Errorf("snapshot manifest encode: %w", err) } manifestJSON = append(manifestJSON, '\n') if err := os.WriteFile(manifestPath, manifestJSON, 0o600); err != nil { return fmt.Errorf("snapshot manifest write: %w", err) } if err := syncFile(manifestPath); err != nil { return fmt.Errorf("snapshot manifest sync: %w", err) } if err := os.Rename(partial, targetAbs); err != nil { return fmt.Errorf("snapshot finalize: %w", err) } complete = true log.Printf("snapshot complete target=%s files=%d bytes=%d index_records=%d precise_copied=%d pending_mbox=%d pending_target=%d", targetAbs, len(manifest.Files), snapshotBytes(manifest.Files), manifest.IndexRecords, manifest.PreciseCopied, manifest.PendingMbox, manifest.PendingTarget) return nil } type snapshotStats struct { IndexRecords int64 PreciseCopied int64 PendingMbox int64 PendingTarget int64 MissingArchiveRecords int64 InvalidStageOrder int64 IndexSHA256 string } func snapshotDatabaseStats(db *sql.DB) (snapshotStats, error) { var stats snapshotStats queries := []struct { query string dest *int64 }{ {`SELECT count(*) FROM mbox_index`, &stats.IndexRecords}, {`SELECT count(*) FROM copied WHERE body_sha256<>''`, &stats.PreciseCopied}, {`SELECT count(*) FROM copied WHERE body_sha256<>'' AND mbox_done=0`, &stats.PendingMbox}, {`SELECT count(*) FROM copied WHERE body_sha256<>'' AND target_done=0`, &stats.PendingTarget}, {`SELECT count(*) FROM copied WHERE body_sha256<>'' AND target_done=1 AND mbox_done=0`, &stats.InvalidStageOrder}, {`SELECT count(*) FROM copied c WHERE c.body_sha256<>'' AND c.mbox_done=1 AND NOT EXISTS ( SELECT 1 FROM mbox_index i WHERE i.account_id=c.account_id AND i.folder=c.folder AND i.message_id=c.message_id AND i.body_sha256=c.body_sha256 )`, &stats.MissingArchiveRecords}, } for _, item := range queries { if err := db.QueryRow(item.query).Scan(item.dest); err != nil { return snapshotStats{}, fmt.Errorf("snapshot database statistics: %w", err) } } return stats, nil } func indexDigest(db *sql.DB) (string, error) { rows, err := db.Query(`SELECT account_id, folder, seq, message_id, body_sha256 FROM mbox_index ORDER BY account_id, folder, seq`) if err != nil { return "", fmt.Errorf("snapshot index digest query: %w", err) } defer rows.Close() hash := sha256.New() for rows.Next() { var accountID, seq int64 var folder, messageID, bodyHash string if err := rows.Scan(&accountID, &folder, &seq, &messageID, &bodyHash); err != nil { return "", fmt.Errorf("snapshot index digest row: %w", err) } for _, value := range []int64{accountID, seq} { if err := binary.Write(hash, binary.LittleEndian, value); err != nil { return "", err } } for _, value := range []string{folder, messageID, bodyHash} { if err := binary.Write(hash, binary.LittleEndian, uint64(len(value))); err != nil { return "", err } if _, err := io.WriteString(hash, value); err != nil { return "", err } } } if err := rows.Err(); err != nil { return "", fmt.Errorf("snapshot index digest: %w", err) } return hex.EncodeToString(hash.Sum(nil)), nil } func collectSnapshotWatermarks(db *sql.DB, mboxRoot string) ([]snapshotWatermark, error) { rootAbs, err := filepath.Abs(mboxRoot) if err != nil { return nil, fmt.Errorf("snapshot mbox root: %w", err) } rows, err := db.Query(`SELECT i.account_id, a.name, a.mbox_dir, i.folder, MAX(i.file_offset+i.frame_len), count(*) FROM mbox_index i JOIN accounts a ON a.id=i.account_id GROUP BY i.account_id, a.name, a.mbox_dir, i.folder ORDER BY lower(a.name), a.name, i.folder`) if err != nil { return nil, fmt.Errorf("snapshot watermarks: %w", err) } defer rows.Close() var out []snapshotWatermark seenPaths := map[string]bool{} archivePaths := map[string]map[string]string{} for rows.Next() { var watermark snapshotWatermark if err := rows.Scan(&watermark.AccountID, &watermark.Account, &watermark.MboxDir, &watermark.Folder, &watermark.Bytes, &watermark.IndexRecords); err != nil { return nil, fmt.Errorf("snapshot watermark row: %w", err) } watermark.MboxDir = strings.TrimSpace(watermark.MboxDir) if watermark.MboxDir == "" { return nil, fmt.Errorf("snapshot account %s has no mbox_dir", watermark.Account) } paths, ok := archivePaths[watermark.MboxDir] if !ok { paths = map[string]string{} for _, path := range archiveMboxPaths(filepath.Join(rootAbs, watermark.MboxDir)) { paths[folderNameFromMboxPath(path)] = path } archivePaths[watermark.MboxDir] = paths } watermark.SourcePath = paths[watermark.Folder] if watermark.SourcePath == "" { return nil, fmt.Errorf("snapshot indexed mbox missing for account=%s folder=%q", watermark.Account, watermark.Folder) } pathAbs, err := filepath.Abs(watermark.SourcePath) if err != nil { return nil, fmt.Errorf("snapshot mbox path: %w", err) } relative, err := filepath.Rel(rootAbs, pathAbs) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) || filepath.IsAbs(relative) { return nil, fmt.Errorf("snapshot mbox escapes configured root: %s", pathAbs) } watermark.SourcePath = pathAbs watermark.RelativePath = relative pathKey := snapshotPathKey(relative) if seenPaths[pathKey] { return nil, fmt.Errorf("snapshot multiple index groups resolve to the same mbox: %s", relative) } seenPaths[pathKey] = true info, err := os.Stat(pathAbs) if err != nil { return nil, fmt.Errorf("snapshot mbox stat %s: %w", pathAbs, err) } if !info.Mode().IsRegular() { return nil, fmt.Errorf("snapshot mbox is not a regular file: %s", pathAbs) } if watermark.Bytes <= 0 || info.Size() < watermark.Bytes { return nil, fmt.Errorf("snapshot invalid watermark account=%s folder=%q boundary=%d size=%d", watermark.Account, watermark.Folder, watermark.Bytes, info.Size()) } out = append(out, watermark) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("snapshot watermarks: %w", err) } return out, nil } func copyFilePrefix(source, destination string, length int64) (string, error) { if length < 0 { return "", fmt.Errorf("negative prefix length %d", length) } if err := os.MkdirAll(filepath.Dir(destination), 0o700); err != nil { return "", err } src, err := os.Open(source) if err != nil { return "", err } defer src.Close() dst, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return "", err } hash := sha256.New() written, copyErr := io.CopyN(io.MultiWriter(dst, hash), src, length) if copyErr == nil && written != length { copyErr = io.ErrShortWrite } if copyErr == nil { copyErr = dst.Sync() } closeErr := dst.Close() if copyErr != nil { return "", copyErr } if closeErr != nil { return "", closeErr } return hex.EncodeToString(hash.Sum(nil)), nil } func openSnapshotDatabase(path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open snapshot database: %w", err) } db.SetMaxOpenConns(1) if _, err := db.Exec(`PRAGMA query_only=ON; PRAGMA foreign_keys=ON;`); err != nil { _ = db.Close() return nil, fmt.Errorf("open snapshot database read-only: %w", err) } return db, nil } // VerifySnapshot validates hashes and DB/file boundaries, then copies only the // small DB into a temporary restore workspace and performs a full reindex there. // The snapshot mbox files and any live database remain untouched. func VerifySnapshot(root string) error { root = strings.TrimSpace(root) if root == "" { return fmt.Errorf("verify snapshot: directory is empty") } rootAbs, err := filepath.Abs(root) if err != nil { return fmt.Errorf("verify snapshot path: %w", err) } manifestBytes, err := os.ReadFile(filepath.Join(rootAbs, "snapshot-manifest.json")) if err != nil { return fmt.Errorf("verify snapshot manifest: %w", err) } var manifest SnapshotManifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return fmt.Errorf("verify snapshot manifest decode: %w", err) } if manifest.FormatVersion != snapshotFormatVersion { return fmt.Errorf("verify snapshot format=%d, supported=%d", manifest.FormatVersion, snapshotFormatVersion) } databasePath, err := pathInsideSnapshot(rootAbs, manifest.DatabasePath) if err != nil { return err } if runtime.GOOS != "windows" { databaseInfo, err := os.Stat(databasePath) if err != nil { return fmt.Errorf("verify snapshot database permissions: %w", err) } if permissions := databaseInfo.Mode().Perm(); permissions&0o077 != 0 { return fmt.Errorf("verify snapshot database permissions=%#o, want no group/other access", permissions) } } databaseHash, err := hashFile(databasePath) if err != nil { return fmt.Errorf("verify snapshot database hash: %w", err) } if databaseHash != manifest.DatabaseSHA256 { return fmt.Errorf("verify snapshot database hash mismatch: got %s want %s", databaseHash, manifest.DatabaseSHA256) } for _, file := range manifest.Files { path, err := pathInsideSnapshot(rootAbs, file.Path) if err != nil { return err } info, err := os.Stat(path) if err != nil { return fmt.Errorf("verify snapshot file %s: %w", file.Path, err) } if info.Size() != file.Bytes { return fmt.Errorf("verify snapshot file size %s: got %d want %d", file.Path, info.Size(), file.Bytes) } hash, err := hashFile(path) if err != nil { return fmt.Errorf("verify snapshot file hash %s: %w", file.Path, err) } if hash != file.SHA256 { return fmt.Errorf("verify snapshot file hash mismatch %s: got %s want %s", file.Path, hash, file.SHA256) } } snapshotDB, err := openSnapshotDatabase(databasePath) if err != nil { return err } var integrity string if err := snapshotDB.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil { _ = snapshotDB.Close() return fmt.Errorf("verify snapshot integrity: %w", err) } if integrity != "ok" { _ = snapshotDB.Close() return fmt.Errorf("verify snapshot integrity: %s", integrity) } stats, err := snapshotDatabaseStats(snapshotDB) if err == nil { stats.IndexSHA256, err = indexDigest(snapshotDB) } if err != nil { _ = snapshotDB.Close() return err } watermarks, err := collectSnapshotWatermarks(snapshotDB, filepath.Join(rootAbs, "backup")) if closeErr := snapshotDB.Close(); err == nil && closeErr != nil { err = closeErr } if err != nil { return err } if stats.IndexRecords != manifest.IndexRecords || stats.PreciseCopied != manifest.PreciseCopied || stats.PendingMbox != manifest.PendingMbox || stats.PendingTarget != manifest.PendingTarget || stats.InvalidStageOrder != manifest.InvalidStageOrder || stats.MissingArchiveRecords != manifest.MissingArchiveRecords || stats.IndexSHA256 != manifest.IndexSHA256 { return fmt.Errorf("verify snapshot manifest/database statistics differ") } if stats.MissingArchiveRecords != 0 { return fmt.Errorf("verify snapshot has %d mbox_done identities without archive index", stats.MissingArchiveRecords) } if stats.InvalidStageOrder != 0 { return fmt.Errorf("verify snapshot has %d precise identities with invalid stage order", stats.InvalidStageOrder) } if err := compareManifestWatermarks(manifest.Files, watermarks); err != nil { return err } if err := verifySnapshotReindex(rootAbs, databasePath, manifest.IndexRecords, manifest.IndexSHA256); err != nil { return err } log.Printf("snapshot verified root=%s files=%d bytes=%d index_records=%d precise_copied=%d pending_mbox=%d pending_target=%d", rootAbs, len(manifest.Files), snapshotBytes(manifest.Files), manifest.IndexRecords, manifest.PreciseCopied, manifest.PendingMbox, manifest.PendingTarget) return nil } func compareManifestWatermarks(files []SnapshotFile, watermarks []snapshotWatermark) error { manifestByPath := make(map[string]SnapshotFile, len(files)) for _, file := range files { key := snapshotPathKey(filepath.FromSlash(file.Path)) if _, exists := manifestByPath[key]; exists { return fmt.Errorf("verify snapshot duplicate manifest path: %s", file.Path) } manifestByPath[key] = file } if len(manifestByPath) != len(watermarks) { return fmt.Errorf("verify snapshot file/watermark count differs: manifest=%d database=%d", len(manifestByPath), len(watermarks)) } for _, watermark := range watermarks { path := filepath.Join("backup", watermark.RelativePath) key := snapshotPathKey(path) file, ok := manifestByPath[key] if !ok { return fmt.Errorf("verify snapshot manifest missing DB watermark: %s", filepath.ToSlash(path)) } if file.Bytes != watermark.Bytes || file.IndexRecords != watermark.IndexRecords || file.AccountID != watermark.AccountID || file.Folder != watermark.Folder { return fmt.Errorf("verify snapshot watermark mismatch: %s", file.Path) } } return nil } func verifySnapshotReindex(root, databasePath string, expectedIndexRecords int64, expectedIndexSHA256 string) error { temporaryRoot, err := os.MkdirTemp("", "mail-graveyard-snapshot-verify-") if err != nil { return fmt.Errorf("verify snapshot temporary restore: %w", err) } defer os.RemoveAll(temporaryRoot) temporaryDB := filepath.Join(temporaryRoot, "mail-graveyard.db") databaseInfo, err := os.Stat(databasePath) if err != nil { return fmt.Errorf("verify snapshot temporary database stat: %w", err) } if _, err := copyFilePrefix(databasePath, temporaryDB, databaseInfo.Size()); err != nil { return fmt.Errorf("verify snapshot temporary database copy: %w", err) } oldCfg, oldDB := Cfg, DB Cfg.DBPath = temporaryDB Cfg.MboxRoot = filepath.Join(root, "backup") DB = nil defer func() { if DB != nil { _ = DB.Close() } Cfg, DB = oldCfg, oldDB }() if err := ConnectDB(false); err != nil { return fmt.Errorf("verify snapshot restore database: %w", err) } if _, err := DB.Exec(`CREATE TABLE snapshot_verify_expected_index AS SELECT account_id, folder, seq, message_id, body_sha256 FROM mbox_index`); err != nil { return fmt.Errorf("verify snapshot preserve expected index: %w", err) } if err := ReindexArchives("all"); err != nil { return fmt.Errorf("verify snapshot restore reindex: %w", err) } var reindexed int64 if err := DB.QueryRow(`SELECT count(*) FROM mbox_index`).Scan(&reindexed); err != nil { return fmt.Errorf("verify snapshot restore index count: %w", err) } if reindexed != expectedIndexRecords { return fmt.Errorf("verify snapshot restore index count=%d, want %d", reindexed, expectedIndexRecords) } reindexedDigest, err := indexDigest(DB) if err != nil { return err } if reindexedDigest != expectedIndexSHA256 { difference, differenceErr := snapshotIndexDifference(DB) if differenceErr != nil { return fmt.Errorf("verify snapshot restore index digest=%s, want %s (difference query: %v)", reindexedDigest, expectedIndexSHA256, differenceErr) } return fmt.Errorf("verify snapshot restore index digest=%s, want %s (%s)", reindexedDigest, expectedIndexSHA256, difference) } stats, err := snapshotDatabaseStats(DB) if err != nil { return err } if stats.MissingArchiveRecords != 0 { return fmt.Errorf("verify snapshot restore has %d copied identities without archive index", stats.MissingArchiveRecords) } if stats.InvalidStageOrder != 0 { return fmt.Errorf("verify snapshot restore has %d precise identities with invalid stage order", stats.InvalidStageOrder) } return nil } func snapshotIndexDifference(db *sql.DB) (string, error) { type differenceRow struct { accountID int64 folder string seq int64 messageID string bodyHash string } queries := []struct { label string left string right string }{ {"missing after reindex", "snapshot_verify_expected_index", "mbox_index"}, {"unexpected after reindex", "mbox_index", "snapshot_verify_expected_index"}, } parts := make([]string, 0, len(queries)) for _, query := range queries { statement := fmt.Sprintf(`SELECT account_id, folder, seq, message_id, body_sha256 FROM %s EXCEPT SELECT account_id, folder, seq, message_id, body_sha256 FROM %s`, query.left, query.right) var count int64 if err := db.QueryRow(`SELECT count(*) FROM (` + statement + `)`).Scan(&count); err != nil { return "", err } rows, err := db.Query(statement + ` LIMIT 3`) if err != nil { return "", err } var samples []string for rows.Next() { var row differenceRow if err := rows.Scan(&row.accountID, &row.folder, &row.seq, &row.messageID, &row.bodyHash); err != nil { _ = rows.Close() return "", err } hash := row.bodyHash if len(hash) > 16 { hash = hash[:16] } samples = append(samples, fmt.Sprintf("account=%d folder=%q seq=%d id=%q hash=%s", row.accountID, row.folder, row.seq, row.messageID, hash)) } if err := rows.Err(); err != nil { _ = rows.Close() return "", err } if err := rows.Close(); err != nil { return "", err } parts = append(parts, fmt.Sprintf("%s=%d samples=[%s]", query.label, count, strings.Join(samples, "; "))) } return strings.Join(parts, ", "), nil } func pathInsideSnapshot(root, relative string) (string, error) { if relative == "" || filepath.IsAbs(relative) { return "", fmt.Errorf("verify snapshot invalid relative path: %q", relative) } path := filepath.Join(root, filepath.FromSlash(relative)) pathAbs, err := filepath.Abs(path) if err != nil { return "", err } rel, err := filepath.Rel(root, pathAbs) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("verify snapshot path escapes root: %q", relative) } return pathAbs, nil } func hashFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() hash := sha256.New() if _, err := io.Copy(hash, f); err != nil { return "", err } return hex.EncodeToString(hash.Sum(nil)), nil } func syncFile(path string) error { f, err := os.OpenFile(path, os.O_RDWR, 0) if err != nil { return err } defer f.Close() return f.Sync() } func snapshotBytes(files []SnapshotFile) int64 { var total int64 for _, file := range files { total += file.Bytes } return total } func snapshotPathKey(path string) string { path = filepath.Clean(path) if runtime.GOOS == "windows" { return strings.ToLower(path) } return path } func sortSnapshotFiles(files []SnapshotFile) { sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) }