diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 45806bc335fe..b9c1f8070b1d 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -1,4 +1,5 @@ // Covers backup archive creation and verification filtering. +import { rmSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -20,6 +21,7 @@ import { formatBackupCreateSummary, type BackupCreateResult, } from "./backup-create.js"; +import { isVolatileBackupPath } from "./backup-volatile-filter.js"; import { requireNodeSqlite } from "./node-sqlite.js"; function makeResult(overrides: Partial = {}): BackupCreateResult { @@ -291,6 +293,52 @@ describe("writeTarArchiveWithRetry", () => { }); }); +describe("createBackupVolatileStatCache", () => { + it("lets tar filter a volatile file that disappears before lstat", async () => { + await withOpenClawTestState( + { + layout: "state-only", + prefix: "openclaw-backup-volatile-stat-cache-", + scenario: "minimal", + }, + async (state) => { + const volatilePath = await state.writeText("logs/gateway.log", "live log\n"); + await state.writeText("settings.json", '{"keep":true}\n'); + const archivePath = state.path("volatile-stat-cache.tar.gz"); + const volatilePlan = { stateDirs: [state.stateDir] }; + const statCache = backupCreateInternals.createBackupVolatileStatCache(volatilePlan); + const getCachedStat = statCache.get.bind(statCache); + let removedBeforeStat = false; + + statCache.get = (key: string) => { + if (path.resolve(key) === path.resolve(volatilePath)) { + rmSync(volatilePath, { force: true }); + removedBeforeStat = true; + } + return getCachedStat(key); + }; + + await tar.c( + { + file: archivePath, + gzip: true, + portable: true, + preservePaths: true, + statCache, + filter: (entryPath) => !isVolatileBackupPath(entryPath, volatilePlan), + }, + [state.stateDir], + ); + + const entries = await listArchiveEntries(archivePath); + expect(removedBeforeStat).toBe(true); + expect(entries.some((entry) => entry.endsWith("/settings.json"))).toBe(true); + expect(entries.some((entry) => entry.endsWith("/logs/gateway.log"))).toBe(false); + }, + ); + }); +}); + describe("buildExtensionsNodeModulesFilter", () => { it("excludes dependency trees only under state extensions", () => { const filter = buildExtensionsNodeModulesFilter("/state/"); diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index 38b2885d77dd..5a89cd804c0a 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -1,6 +1,6 @@ // Creates backup archives while filtering volatile runtime state. import { randomUUID } from "node:crypto"; -import { constants as fsConstants } from "node:fs"; +import { constants as fsConstants, type Stats } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -43,6 +43,40 @@ class BackupLinkCache extends Map { } } +type VolatileFilterPlan = Parameters[1]; + +const VOLATILE_BACKUP_SYNTHETIC_STAT = { + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => false, + isFile: () => false, + isSocket: () => false, + isSymbolicLink: () => false, +} as unknown as Stats; + +class BackupVolatileStatCache extends Map { + constructor(private readonly volatilePlan: VolatileFilterPlan) { + super(); + } + + override get(key: string): Stats | undefined { + const cached = super.get(key); + if (cached) { + return cached; + } + // node-tar checks this cache before lstat and applies the filter to a hit. + // A synthetic hit lets known volatile paths disappear without aborting the archive. + return isVolatileBackupPath(key, this.volatilePlan) + ? VOLATILE_BACKUP_SYNTHETIC_STAT + : undefined; + } +} + +function createBackupVolatileStatCache(volatilePlan: VolatileFilterPlan): Map { + return new BackupVolatileStatCache(volatilePlan); +} + export type BackupCreateOptions = { output?: string; dryRun?: boolean; @@ -192,7 +226,11 @@ async function writeTarArchiveWithRetry(params: { throw new Error(`Backup archive write failed: ${final.message}${suffix}`, { cause: final }); } -export const testApi = { writeTarArchiveWithRetry, isTarEofRaceError }; +export const testApi = { + writeTarArchiveWithRetry, + isTarEofRaceError, + createBackupVolatileStatCache, +}; export { testApi as __test }; async function resolveOutputPath(params: { @@ -844,6 +882,7 @@ export async function createBackupArchive( portable: true, preservePaths: true, linkCache: new BackupLinkCache(), + statCache: createBackupVolatileStatCache(volatilePlan), filter: tarFilter, onWriteEntry: (entry) => { entry.path = remapArchiveEntryPath({