fix(studio-server): a request that fails after its project folder vanished says the folder is gone (#4617)

* fix(studio-server): a save never recreates a project folder renamed away

* style(cli): wrap imports after merging main

* fix(studio-server): proxy and upload answer that the project folder is gone instead of 502 or an empty 201

* test(studio-server): rename the project folder while an upload is being read

* fix(studio-server): a proxy or backup answers that a vanished project folder is gone

* test(studio-server): clean up the renamed folder after the proxy test

* refactor(studio-server): drop an unused re-export and share the folder-gone test setup

* fix(studio-server): any request failing after its project folder vanished says the folder is gone

* fix(studio-server): the folder-gone answer works on every host and drops the failed route's headers

* fix(studio-server): the folder-gone 404 keeps the host's own headers

* fix(core): no folder outside a project is created once the project folder is gone

* test(studio-server): a rename that makes the codec probe fail answers that the folder is gone

* test(studio-server): a refused render leaves no outside renders folder behind

* docs(core): wrap the mkdirWithinProject comment under the line limit

* refactor(studio-server): let the folder-gone middleware's names carry what its comments said

* fix(studio-server): say the folder is gone when a render names a composition

* fix(studio-server): check renders against the folder path and share one folder-gone answer

* test(studio-server): pass the project folder to the preview document store
This commit is contained in:
Miguel Ángel
2026-09-28 15:47:13 +00:00
committed by GitHub
parent d2f0bc7f34
commit addc72d761
13 changed files with 277 additions and 17 deletions
+1
View File
@@ -281,6 +281,7 @@ export {
} from "./inline-scripts/parityContract";
export { redactKnownPaths, redactTelemetryString } from "./telemetryRedaction";
export {
folderGone,
isProjectRootMissing,
isSafePath,
mkdirWithinProject,
+39 -1
View File
@@ -1,8 +1,17 @@
import { describe, it, expect, afterEach } from "vitest";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
writeFileSync,
symlinkSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import {
folderGone,
isProjectRootMissing,
isSafePath,
mkdirWithinProject,
@@ -225,3 +234,32 @@ describe("mkdirWithinProject", () => {
expect(existsSync(join(parent, "renders"))).toBe(false);
});
});
describe("folderGone", () => {
it("is true only when nothing is there", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-folder-gone-"));
writeFileSync(join(dir, "file"), "");
try {
expect(folderGone(dir)).toBe(false);
expect(folderGone(join(dir, "missing"))).toBe(true);
expect(folderGone(join(dir, "file", "below"))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"is false for a folder that cannot be looked at",
() => {
const dir = mkdtempSync(join(tmpdir(), "hf-folder-gone-"));
mkdirSync(join(dir, "film"));
chmodSync(dir, 0o000);
try {
expect(folderGone(join(dir, "film"))).toBe(false);
} finally {
chmodSync(dir, 0o700);
rmSync(dir, { recursive: true, force: true });
}
},
);
});
+13 -4
View File
@@ -1,5 +1,5 @@
import { resolve, sep, join, dirname, basename, relative, isAbsolute } from "node:path";
import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
import { lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
export function realpath(path: string): string {
try {
@@ -110,12 +110,21 @@ export class ProjectRootMissingError extends Error {
export const isProjectRootMissing = (error: unknown): boolean =>
error instanceof Error && error.name === "ProjectRootMissingError";
/** True only when nothing is at `dir` any more; a folder that cannot be looked at (EACCES, EIO) is not gone. */
export function folderGone(dir: string): boolean {
try {
return !statSync(dir, { throwIfNoEntry: false });
} catch {
return false;
}
}
/** The project folder's real path; ProjectRootMissingError when it is gone. */
export function realProjectRoot(root: string): string {
try {
return realpath(root);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new ProjectRootMissingError(root);
if (folderGone(root)) throw new ProjectRootMissingError(root);
throw error;
}
}
@@ -125,7 +134,7 @@ export function realProjectRoot(root: string): string {
* A `dir` outside `root` is created recursively, but only while `root` exists.
*/
export function mkdirWithinProject(root: string, dir: string): void {
if (!existsSync(root)) throw new ProjectRootMissingError(root);
if (folderGone(root)) throw new ProjectRootMissingError(root);
const inside = relative(resolve(root), resolve(dir));
if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
mkdirSync(dir, { recursive: true });
@@ -138,7 +147,7 @@ export function mkdirWithinProject(root: string, dir: string): void {
mkdirSync(path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" && !existsSync(root)) throw new ProjectRootMissingError(root);
if (code === "ENOENT" && folderGone(root)) throw new ProjectRootMissingError(root);
if (code !== "EEXIST") throw error;
}
}
+16 -3
View File
@@ -14,8 +14,8 @@ import { registerSelectionRoutes } from "./routes/selection.js";
import { registerMediaRoutes } from "./routes/media.js";
import { registerGlobalAssetRoutes } from "./routes/globalAssets.js";
import { registerHistoryRoutes } from "./routes/history.js";
import { projectDirMissing } from "./helpers/projectDirMissing.js";
import { isProjectRootMissing } from "./helpers/safePath.js";
import { replaceWithProjectDirMissing } from "./helpers/projectDirMissing.js";
import { folderGone, isProjectRootMissing } from "./helpers/safePath.js";
/**
* Create a Hono sub-app with all studio API routes.
@@ -26,8 +26,21 @@ import { isProjectRootMissing } from "./helpers/safePath.js";
export function createStudioApi(adapter: StudioApiAdapter): Hono {
const api = new Hono();
api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
const hostHeaders = new Headers(c.res.headers);
await next();
if (isProjectRootMissing(c.error)) c.res = projectDirMissing(c);
if (isProjectRootMissing(c.error)) replaceWithProjectDirMissing(c, hostHeaders);
});
api.use("/projects/:id/*", async function answerProjectDirMissingForVanishedFolder(c, next) {
const hostHeaders = new Headers(c.res.headers);
const dirBeforeRoute = await Promise.resolve()
.then(() => adapter.resolveProject(c.req.param("id")))
.then(
(project) => project?.dir,
() => undefined,
);
await next();
if (c.res.status >= 403 && dirBeforeRoute && folderGone(dirBeforeRoute))
replaceWithProjectDirMissing(c, hostHeaders);
});
registerProjectRoutes(api, adapter);
@@ -174,4 +174,20 @@ describe("insertCompositionIntoSource", () => {
expect(() => insert("invalid.html")).toThrow(/valid data-composition-duration/);
expect(() => insert("../outside.html")).toThrow(CompositionInsertionError);
});
it("says the project folder is gone rather than that the source escapes it", () => {
const dir = project();
rmSync(dir, { recursive: true });
const insert = () =>
insertCompositionIntoSource({
projectDir: dir,
targetPath: "index.html",
sourcePath: "headline.html",
parentSource: parent,
start: 0,
desiredTrack: 0,
});
expect(insert).toThrow(expect.objectContaining({ name: "ProjectRootMissingError" }));
});
});
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { dirname, relative, resolve, sep } from "node:path";
import { parseHTML } from "linkedom";
import { isSafePath, realpath, resolveWithinProject } from "./safePath.js";
import { ProjectRootMissingError } from "@hyperframes/core";
import { folderGone, isSafePath, realpath, resolveWithinProject } from "./safePath.js";
export class CompositionInsertionError extends Error {
constructor(
@@ -38,6 +39,7 @@ function positiveAttribute(root: Element, ...names: string[]): number {
function canonicalProjectPath(projectDir: string, candidate: string | null): string {
if (!candidate) {
if (folderGone(projectDir)) throw new ProjectRootMissingError(projectDir);
throw new CompositionInsertionError("Composition source escapes the project", 400);
}
if (!existsSync(candidate)) {
@@ -1,3 +1,13 @@
import type { Context } from "hono";
const PROJECT_DIR_MISSING = { error: "not found", why: "project_dir_missing" };
export const projectDirMissing = (c: {
json: (data: { error: string; why: string }, status: 404) => Response;
}) => c.json({ error: "not found", why: "project_dir_missing" }, 404);
}) => c.json(PROJECT_DIR_MISSING, 404);
export function replaceWithProjectDirMissing(c: Context, hostHeaders: Headers): void {
hostHeaders.delete("content-type");
c.res = undefined;
c.res = Response.json(PROJECT_DIR_MISSING, { status: 404, headers: hostHeaders });
}
@@ -6,6 +6,7 @@ import { realpath, resolveWithinProject } from "@hyperframes/core";
// CLI, engine) can share it without a backwards dependency on studio-api.
// Re-exported here for back-compat with existing `../helpers/safePath.js` imports.
export {
folderGone,
isProjectRootMissing,
isSafePath,
mkdirWithinProject,
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi, type TestContext } from "vitest";
import { Hono } from "hono";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -15,6 +16,7 @@ import { join, relative } from "node:path";
import { registerFileRoutes } from "./files";
import { createStudioApi } from "../createStudioApi";
import { fileContentVersion } from "../helpers/fileVersion";
import { ProjectRootMissingError } from "@hyperframes/core";
import { mkdirWithinProject } from "../helpers/safePath";
import type { StudioApiAdapter } from "../types";
@@ -308,6 +310,123 @@ describe("resolveProjectPath why", () => {
await expectProjectGone(await upload(createStudioApi(adapter)), project);
});
describe.each([
["a host that keeps resolving the project", (adapter: StudioApiAdapter) => adapter],
[
"a host that stops resolving a vanished project",
(adapter: StudioApiAdapter): StudioApiAdapter => ({
...adapter,
resolveProject: async (id) => {
const project = await adapter.resolveProject(id);
return project && existsSync(project.dir) ? project : null;
},
}),
],
])("on %s", (_, host) => {
function vanishWhileReading(
read: "arrayBuffer" | "text",
method: string,
route: string,
body: string,
) {
const { project, adapter } = fixture();
const readBody = Request.prototype[read];
vi.spyOn(Request.prototype, read).mockImplementation(function (this: Request) {
renameSync(project, `${project}-renamed`);
return readBody.call(this);
});
const response = createStudioApi(host(adapter)).request(
`http://localhost/projects/demo/${route}`,
{
method,
headers: { "Content-Type": "application/json", "If-Match": fileContentVersion("inside") },
body,
},
);
return { project, response };
}
it.each([
["a save", "arrayBuffer", "PUT", "files/inside.txt", "new"],
["a rename", "text", "PATCH", "files/inside.txt", JSON.stringify({ newPath: "moved.txt" })],
["a duplicate", "text", "POST", "duplicate-file", JSON.stringify({ path: "inside.txt" })],
[
"a render that names a composition",
"text",
"POST",
"render",
JSON.stringify({ composition: "index.html" }),
],
] as const)(
"answers that the project folder is gone when it vanishes while %s reads its body",
async (_, read, method, route, body) => {
const { project, response } = vanishWhileReading(read, method, route, body);
await expectProjectGone(await response, project);
},
);
it("keeps a malformed request's 400 when the folder vanishes", async () => {
const { response } = vanishWhileReading("text", "PATCH", "files/inside.txt", "{}");
expect((await response).status).toBe(400);
});
it("keeps a save conflict's 409 while the folder is there", async () => {
const { adapter } = fixture();
const response = await createStudioApi(host(adapter)).request(fileUrl("inside.txt"), {
method: "PUT",
headers: { "If-Match": fileContentVersion("stale") },
body: "new",
});
expect(response.status).toBe(409);
});
});
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps a route's error when the project folder is there but cannot be looked at",
async () => {
const { project, adapter } = fixture();
const api = createStudioApi(adapter);
api.get("/projects/:id/broken", () => {
throw new Error("read failed");
});
chmodSync(join(project, ".."), 0o000);
try {
expect((await api.request("http://localhost/projects/demo/broken")).status).toBe(500);
} finally {
chmodSync(join(project, ".."), 0o700);
}
},
);
it.each([
["an error after the folder vanished", () => new Error("read failed"), true],
["the missing-folder error", () => new ProjectRootMissingError("gone"), false],
])(
"keeps the host's headers but not the route's on the 404 when a route throws %s",
async (_, error, removeFolder) => {
const { project, adapter } = fixture();
const api = createStudioApi(adapter);
api.get("/projects/:id/cached", (c) => {
c.header("ETag", '"thumb"');
throw error();
});
const host = new Hono();
host.use(async (c, next) => {
c.header("Access-Control-Allow-Origin", "*");
await next();
});
host.route("/api", api);
if (removeFolder) rmSync(project, { recursive: true, force: true });
const response = await host.request("http://localhost/api/projects/demo/cached");
expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
expect(response.headers.get("ETag")).toBeNull();
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
},
);
it("does not start a render into an outside folder once the project folder is gone", async () => {
const { project, adapter } = fixture();
const startRender = vi.fn(adapter.startRender);
@@ -324,6 +443,30 @@ describe("resolveProjectPath why", () => {
expect(existsSync(adapter.rendersDir({ id: "demo", dir: project }))).toBe(false);
});
it("refuses a render composition that leaves a symlinked project folder through ..", async (context) => {
const { project, adapter } = fixture();
const root = join(project, "..");
mkdirSync(join(root, "data"));
renameSync(project, join(root, "data", "project"));
mkdirSync(join(root, "home", "project"), { recursive: true });
writeFileSync(join(root, "home", "project", "secret.html"), "<html></html>");
linkOrSkip(context, join(root, "data", "project"), join(root, "home", "link"), "dir");
const startRender = vi.fn(adapter.startRender);
const api = createStudioApi({
...adapter,
resolveProject: async (id) => ({ id, dir: join(root, "home", "link") }),
startRender,
});
const response = await api.request("http://localhost/projects/demo/render", {
method: "POST",
body: JSON.stringify({ composition: "../project/secret.html" }),
});
expect(response.status).toBe(400);
expect(startRender).not.toHaveBeenCalled();
});
it("reports a missing project directory as 404, not 403", async () => {
const { app, project } = fixture();
rmSync(project, { recursive: true, force: true });
+4 -3
View File
@@ -27,6 +27,7 @@ import { replaceFileAtomically } from "../helpers/atomicFile.js";
import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import {
folderGone,
isSafePath,
mkdirWithinProject,
pinWithinProject,
@@ -197,7 +198,7 @@ async function resolveProjectPath(
// `realpathSync(base)` throws when the base itself is gone) and reported as
// `403 forbidden` — indistinguishable from a real path-traversal attempt.
// Checked here, once, so every route built on this shares the fix.
if (!existsSync(project.dir)) {
if (folderGone(project.dir)) {
return { error: projectDirMissing(c) } as const;
}
@@ -3252,7 +3253,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
if (!existsSync(project.dir)) return projectDirMissing(c);
if (folderGone(project.dir)) return projectDirMissing(c);
// Optional subdirectory within the project (e.g. "assets/audio")
const subDir = c.req.query("dir") ?? "";
@@ -3262,7 +3263,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const formData = await c.req.formData();
mkdirWithinProject(project.dir, targetDir);
const result = await processUploadedFiles(formData, targetDir, project.dir);
if (!existsSync(project.dir)) return projectDirMissing(c);
if (folderGone(project.dir)) return projectDirMissing(c);
return c.json(
{
@@ -1798,6 +1798,31 @@ describe("hf-proxy codec probe", () => {
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
});
it("answers that the project folder is gone when a rename makes the codec probe fail", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
vi.resetModules();
vi.doMock("../helpers/mediaMetadata.js", async () => ({
...(await vi.importActual<typeof import("../helpers/mediaMetadata.js")>(
"../helpers/mediaMetadata.js",
)),
probeMediaMetadata: async () => {
tempDirs.push(`${projectDir}-renamed`);
renameSync(projectDir, `${projectDir}-renamed`);
return { kind: "video" as const, color: {}, probeError: "ffprobe failed" };
},
}));
const { createStudioApi: create } = await import("../createStudioApi.js");
const api = create(createAdapter(projectDir));
const response = await api.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
);
expect(response.status).toBe(404);
expect(await response.json()).toMatchObject({ why: "project_dir_missing" });
});
it("runs ffprobe once for repeated proxy requests of the same unchanged clip", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
@@ -1,8 +1,7 @@
import { existsSync } from "node:fs";
import type { Hono } from "hono";
import type { StudioApiAdapter } from "../types.js";
import { projectDirMissing } from "../helpers/projectDirMissing.js";
import { isProjectRootMissing } from "../helpers/safePath.js";
import { folderGone, isProjectRootMissing } from "../helpers/safePath.js";
export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): void {
api.get("/registry/blocks", async (c) => {
@@ -20,7 +19,7 @@ export function registerRegistryRoutes(api: Hono, adapter: StudioApiAdapter): vo
}
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "Project not found" }, 404);
if (!existsSync(project.dir)) return projectDirMissing(c);
if (folderGone(project.dir)) return projectDirMissing(c);
const body = await c.req.json<{ blockName?: string }>().catch(() => null);
if (!body?.blockName) {
+3 -1
View File
@@ -5,7 +5,8 @@ import { join } from "node:path";
import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
import { mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js";
import { folderGone, mkdirWithinProject, resolveWithinProject } from "../helpers/safePath.js";
import { projectDirMissing } from "../helpers/projectDirMissing.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
@@ -105,6 +106,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
// resolveWithinProject dereferences symlinks, so an in-project symlink
// pointing outside the root can't smuggle the render target out.
if (!resolveWithinProject(project.dir, body.composition)) {
if (folderGone(project.dir)) return projectDirMissing(c);
return c.json({ error: "composition path must be within the project directory" }, 400);
}
composition = body.composition;