diff --git a/packages/app/tests/hooks/session-backup-gist.test.ts b/packages/app/tests/hooks/session-backup-gist.test.ts new file mode 100644 index 0000000..dce5aa2 --- /dev/null +++ b/packages/app/tests/hooks/session-backup-gist.test.ts @@ -0,0 +1,74 @@ +// CHANGE: add regression coverage for session backup tmp filtering +// WHY: session snapshots must ignore transient tmp directories while preserving persistent files +// REF: issue-198 +// PURITY: SHELL (tests filesystem traversal against committed backup script) + +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import fs from "node:fs" +import path from "node:path" + +import sessionBackupGist from "../../../../scripts/session-backup-gist.js" + +const { collectSessionFiles, shouldIgnoreSessionPath } = sessionBackupGist +const tmpDirPrefix = path.join(process.cwd(), ".tmp-session-backup-gist-") + +const withTempDir = Effect.acquireRelease( + Effect.sync(() => fs.mkdtempSync(tmpDirPrefix)), + (tmpDir) => + Effect.sync(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) +) + +describe("session-backup-gist tmp filtering", () => { + it.effect("ignores tmp directories while keeping persistent session files", () => + Effect.scoped( + Effect.gen(function*(_) { + const tmpDir = yield* _(withTempDir) + const codexDir = path.join(tmpDir, ".codex") + const claudeDir = path.join(tmpDir, ".claude") + + yield* _( + Effect.sync(() => { + fs.mkdirSync(path.join(codexDir, "tmp", "run"), { recursive: true }) + fs.mkdirSync(path.join(codexDir, "memory"), { recursive: true }) + fs.mkdirSync(path.join(claudeDir, "tmp"), { recursive: true }) + fs.mkdirSync(path.join(claudeDir, "profiles"), { recursive: true }) + + fs.writeFileSync(path.join(codexDir, "tmp", "run", ".lock"), "lock") + fs.writeFileSync(path.join(codexDir, "history.jsonl"), "{\"event\":1}\n") + fs.writeFileSync(path.join(codexDir, "memory", "notes.md"), "# notes\n") + fs.writeFileSync(path.join(claudeDir, "tmp", "session.lock"), "lock") + fs.writeFileSync(path.join(claudeDir, "profiles", "default.json"), "{}\n") + }) + ) + + const files = [ + ...collectSessionFiles(codexDir, ".codex", false), + ...collectSessionFiles(claudeDir, ".claude", false) + ] + const logicalNames = files + .map((file) => file.logicalName) + .toSorted((left, right) => left.localeCompare(right)) + + yield* _( + Effect.sync(() => { + expect(logicalNames).toContain(".codex/history.jsonl") + expect(logicalNames).toContain(".codex/memory/notes.md") + expect(logicalNames).toContain(".claude/profiles/default.json") + expect(logicalNames.some((name) => name.split("/").includes("tmp"))).toBe(false) + }) + ) + }) + )) + + it.effect("marks tmp paths for exclusion", () => + Effect.sync(() => { + expect(shouldIgnoreSessionPath("tmp")).toBe(true) + expect(shouldIgnoreSessionPath("tmp/run/.lock")).toBe(true) + expect(shouldIgnoreSessionPath("memory/tmp/run/.lock")).toBe(true) + expect(shouldIgnoreSessionPath("history.jsonl")).toBe(false) + expect(shouldIgnoreSessionPath("memory/notes.md")).toBe(false) + })) +}) diff --git a/scripts/session-backup-gist.d.ts b/scripts/session-backup-gist.d.ts new file mode 100644 index 0000000..84322d8 --- /dev/null +++ b/scripts/session-backup-gist.d.ts @@ -0,0 +1,22 @@ +export type SessionBackupFile = { + readonly logicalName: string + readonly sourcePath: string + readonly size: number +} + +export declare const collectSessionFiles: ( + dirPath: string, + baseName: string, + verbose: boolean +) => ReadonlyArray + +export declare const shouldIgnoreSessionPath: ( + relativePath: string +) => boolean + +declare const sessionBackupGist: { + readonly collectSessionFiles: typeof collectSessionFiles + readonly shouldIgnoreSessionPath: typeof shouldIgnoreSessionPath +} + +export default sessionBackupGist diff --git a/scripts/session-backup-gist.js b/scripts/session-backup-gist.js index 89c13f1..cd9a34f 100644 --- a/scripts/session-backup-gist.js +++ b/scripts/session-backup-gist.js @@ -43,6 +43,14 @@ const { const SESSION_DIR_NAMES = [".codex", ".claude", ".qwen", ".gemini"]; const SESSION_WALK_IGNORE_DIR_NAMES = new Set([".git", "node_modules", "tmp"]); +const toLogicalRelativePath = (relativePath) => + relativePath.split(path.sep).join(path.posix.sep); + +const shouldIgnoreSessionPath = (relativePath) => { + const logicalPath = toLogicalRelativePath(relativePath); + return logicalPath === "tmp" || logicalPath.startsWith("tmp/") || logicalPath.includes("/tmp/"); +}; + const isPathWithinParent = (targetPath, parentPath) => { const relative = path.relative(parentPath, targetPath); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); @@ -365,6 +373,12 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + const logicalRelPath = toLogicalRelativePath(relPath); + + if (shouldIgnoreSessionPath(logicalRelPath)) { + log(verbose, `Skipping tmp path: ${path.posix.join(baseName, logicalRelPath)}`); + continue; + } if (entry.isDirectory()) { if (SESSION_WALK_IGNORE_DIR_NAMES.has(entry.name)) { @@ -374,7 +388,7 @@ const collectSessionFiles = (dirPath, baseName, verbose) => { } else if (entry.isFile()) { try { const stats = fs.statSync(fullPath); - const logicalName = path.posix.join(baseName, relPath.split(path.sep).join(path.posix.sep)); + const logicalName = path.posix.join(baseName, logicalRelPath); files.push({ logicalName, sourcePath: fullPath, @@ -662,4 +676,11 @@ const main = () => { } }; -main(); +if (require.main === module) { + main(); +} + +module.exports = { + collectSessionFiles, + shouldIgnoreSessionPath, +};