diff --git a/.gitignore b/.gitignore index d41fc8793..3d3b82670 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ docs/plans/ # Local proof / test artifacts qa-artifacts/ my-video/ +.hyperframes/backup/ examples/* # Tracked OSS examples — negations override the blanket `examples/*` ignore. !examples/aws-lambda diff --git a/packages/core/src/studio-api/helpers/backupJournal.test.ts b/packages/core/src/studio-api/helpers/backupJournal.test.ts new file mode 100644 index 000000000..46c57689a --- /dev/null +++ b/packages/core/src/studio-api/helpers/backupJournal.test.ts @@ -0,0 +1,88 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-backup-journal-")); + tempDirs.push(projectDir); + return projectDir; +} + +describe("snapshotBeforeWrite", () => { + it("copies the current file bytes before overwrite", () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + const file = join(projectDir, "compositions", "scene.html"); + writeFileSync(file, "before"); + + const result = snapshotBeforeWrite(projectDir, file); + writeFileSync(file, "after"); + + expect(result.backupPath && existsSync(result.backupPath)).toBe(true); + expect(readFileSync(result.backupPath!, "utf-8")).toBe("before"); + expect(backupPathForResponse(projectDir, result.backupPath)).toMatch( + /^\.hyperframes\/backup\//, + ); + }); + + it("creates backups for zero-byte files", () => { + const projectDir = createProjectDir(); + const file = join(projectDir, "empty.html"); + writeFileSync(file, ""); + + const result = snapshotBeforeWrite(projectDir, file); + + expect(result.backupPath && existsSync(result.backupPath)).toBe(true); + expect(readFileSync(result.backupPath!, "utf-8")).toBe(""); + }); + + it("prunes older backups for the same file", () => { + const projectDir = createProjectDir(); + const file = join(projectDir, "index.html"); + writeFileSync(file, "0"); + + for (let i = 1; i <= 5; i += 1) { + writeFileSync(file, String(i)); + snapshotBeforeWrite(projectDir, file, { keepPerFile: 3 }); + } + + expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(3); + }); + + it("does not prune backups for paths with colliding sanitized names", () => { + const projectDir = createProjectDir(); + const first = join(projectDir, "My File.html"); + const second = join(projectDir, "My_File.html"); + writeFileSync(first, "space"); + writeFileSync(second, "underscore"); + + snapshotBeforeWrite(projectDir, first, { keepPerFile: 1 }); + snapshotBeforeWrite(projectDir, second, { keepPerFile: 1 }); + + const backups = readdirSync(join(projectDir, ".hyperframes", "backup")); + expect(backups).toHaveLength(2); + expect( + backups + .map((name) => readFileSync(join(projectDir, ".hyperframes", "backup", name), "utf-8")) + .sort(), + ).toEqual(["space", "underscore"]); + }); +}); diff --git a/packages/core/src/studio-api/helpers/backupJournal.ts b/packages/core/src/studio-api/helpers/backupJournal.ts new file mode 100644 index 000000000..b1f20e6bb --- /dev/null +++ b/packages/core/src/studio-api/helpers/backupJournal.ts @@ -0,0 +1,99 @@ +import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { Buffer } from "node:buffer"; +import { join, relative } from "node:path"; +import { isSafePath } from "./safePath.js"; + +const DEFAULT_KEEP_PER_FILE = 10; + +export interface BackupJournalResult { + backupPath: string | null; + error?: string; +} + +function backupKeyForPath(path: string): string { + return Buffer.from(path, "utf-8").toString("base64url"); +} + +function timestampPrefix(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +export function backupPathForResponse( + projectDir: string, + backupPath: string | null, +): string | null { + if (!backupPath) return null; + const rel = relative(projectDir, backupPath); + if (!rel || rel.startsWith("..")) return null; + return rel.split("\\").join("/"); +} + +export function snapshotBeforeWrite( + projectDir: string, + absPath: string, + options: { keepPerFile?: number } = {}, +): BackupJournalResult { + if (!isSafePath(projectDir, absPath)) return { backupPath: null }; + + try { + const content = readFileSync(absPath); + + const relativePath = relative(projectDir, absPath); + const backupDir = join(projectDir, ".hyperframes", "backup"); + mkdirSync(backupDir, { recursive: true }); + + const backupKey = backupKeyForPath(relativePath); + const backupPath = nextBackupPath(backupDir, backupKey); + writeFileSync(backupPath, content); + pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE); + return { backupPath }; + } catch (error) { + if ( + error && + typeof error === "object" && + "code" in error && + (error.code === "ENOENT" || error.code === "EISDIR") + ) { + return { backupPath: null }; + } + return { backupPath: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +function nextBackupPath(backupDir: string, backupKey: string): string { + const base = `${timestampPrefix()}-${backupKey}`; + let candidate = join(backupDir, base); + let counter = 2; + while (true) { + try { + readFileSync(candidate); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + return candidate; + } + throw error; + } + candidate = join(backupDir, `${base}-${counter}`); + counter += 1; + } +} + +function pruneBackups(backupDir: string, backupKey: string, keepPerFile: number): void { + const keep = Math.max(1, Math.floor(keepPerFile)); + const suffix = `-${backupKey}`; + const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`); + const matches = readdirSync(backupDir) + .filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)) + .map((name) => join(backupDir, name)) + .sort((a, b) => { + return b.localeCompare(a); + }); + + for (const file of matches.slice(keep)) { + try { + unlinkSync(file); + } catch { + // Backup pruning is best-effort and must not block the user's write. + } + } +} diff --git a/packages/core/src/studio-api/helpers/safePath.test.ts b/packages/core/src/studio-api/helpers/safePath.test.ts new file mode 100644 index 000000000..cf4da1b05 --- /dev/null +++ b/packages/core/src/studio-api/helpers/safePath.test.ts @@ -0,0 +1,31 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { walkDir } from "./safePath"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-safe-path-")); + tempDirs.push(projectDir); + return projectDir; +} + +describe("walkDir", () => { + it("hides internal HyperFrames backup files from project listings", () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true }); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup"); + writeFileSync(join(projectDir, "compositions", "scene.html"), "scene"); + + expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]); + }); +}); diff --git a/packages/core/src/studio-api/helpers/safePath.ts b/packages/core/src/studio-api/helpers/safePath.ts index 7a925c3c6..0a4720745 100644 --- a/packages/core/src/studio-api/helpers/safePath.ts +++ b/packages/core/src/studio-api/helpers/safePath.ts @@ -7,7 +7,7 @@ export function isSafePath(base: string, resolved: string): boolean { return resolved.startsWith(norm) || resolved === resolve(base); } -const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]); +const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]); /** Recursively walk a directory and return relative file paths. */ export function walkDir(dir: string, prefix = ""): string[] { diff --git a/packages/core/src/studio-api/routes/files.test.ts b/packages/core/src/studio-api/routes/files.test.ts index 6279d9852..08e8644eb 100644 --- a/packages/core/src/studio-api/routes/files.test.ts +++ b/packages/core/src/studio-api/routes/files.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; @@ -64,6 +64,74 @@ describe("registerFileRoutes", () => { expect(response.status).toBe(404); }); + it("backs up the previous file content before PUT overwrite", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), "before"); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/index.html", { + method: "PUT", + body: "after", + }); + const payload = (await response.json()) as { path?: string; backupPath?: string }; + + expect(response.status).toBe(200); + expect(payload.path).toBe("index.html"); + expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); + expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before"); + expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after"); + }); + + it("backs up the previous file content before delete", async () => { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "index.html"), "before delete"); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request("http://localhost/projects/demo/files/index.html", { + method: "DELETE", + }); + const payload = (await response.json()) as { backupPath?: string }; + + expect(response.status).toBe(200); + expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); + expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before delete"); + }); + + it("backs up the previous file content before structured DOM mutations", async () => { + const projectDir = createProjectDir(); + writeFileSync(projectDir + "/index.html", '