mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* 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
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
import { resolve, sep, join, dirname, basename, relative, isAbsolute } from "node:path";
|
|
import { lstatSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
|
|
export function realpath(path: string): string {
|
|
try {
|
|
return realpathSync.native(path);
|
|
} catch (error) {
|
|
// Some Windows volumes (RAM disks) refuse the native call with EISDIR.
|
|
if ((error as NodeJS.ErrnoException).code === "EISDIR") return realpathSync(path);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// realpath also fails for dangling/cyclic symlinks. Existing entries must not
|
|
// become missing segments: writes could follow them outside the project.
|
|
function isMissingPath(path: string): boolean {
|
|
try {
|
|
lstatSync(path);
|
|
return false;
|
|
} catch (error) {
|
|
return Boolean(
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
(error.code === "ENOENT" || error.code === "ENOTDIR"),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reject paths that escape the `base` directory — including via symlinks.
|
|
*
|
|
* `path.resolve()` collapses `.`/`..` but does NOT dereference symlinks, so a
|
|
* plain prefix check (`resolved.startsWith(base + sep)`) can be defeated by a
|
|
* symlink that lives *inside* `base` but points outside it (e.g.
|
|
* `base/link -> /etc`). A downstream `readFileSync`/`writeFileSync`/`statSync`
|
|
* then follows that link to a file outside `base`. To close this we canonicalize
|
|
* both sides with `realpathSync.native` (the on-disk letter case too) before comparing.
|
|
*
|
|
* The target may not exist yet (e.g. creating a new file), so we canonicalize the
|
|
* deepest *existing* ancestor and re-attach the trailing not-yet-existing
|
|
* segments. Segments that don't exist cannot be symlinks at check time, so they
|
|
* can't redirect the path outside `base` right now. (A symlink swapped in between
|
|
* this check and the subsequent fs call is an inherent TOCTOU race this helper
|
|
* does not, and cannot by itself, defend against.)
|
|
*
|
|
* Lives at the package root rather than under `studio-api/` because callers span
|
|
* layers — `studio-api` routes, the `compiler`, the CLI, and the engine — and
|
|
* `compiler` sits below `studio-api` in the dependency graph, so it cannot import
|
|
* from there without a backwards edge.
|
|
*/
|
|
export function isSafePath(base: string, resolved: string): boolean {
|
|
let baseReal: string;
|
|
try {
|
|
baseReal = realpath(resolve(base));
|
|
} catch {
|
|
// Base must exist and be resolvable; fail closed if not.
|
|
return false;
|
|
}
|
|
|
|
const target = resolve(resolved);
|
|
const trailing: string[] = [];
|
|
let probe = target;
|
|
|
|
for (;;) {
|
|
let ancestorReal: string;
|
|
try {
|
|
ancestorReal = realpath(probe);
|
|
} catch {
|
|
if (!isMissingPath(probe)) return false;
|
|
const parent = dirname(probe);
|
|
if (parent === probe) return false; // walked past the filesystem root
|
|
trailing.push(basename(probe));
|
|
probe = parent;
|
|
continue;
|
|
}
|
|
|
|
// Copy before reverse(): the array is only consumed once today, but a future
|
|
// edit that loops would otherwise silently misorder the rebuilt segments.
|
|
const targetReal = trailing.length
|
|
? join(ancestorReal, ...[...trailing].reverse())
|
|
: ancestorReal;
|
|
return targetReal === baseReal || targetReal.startsWith(baseReal + sep);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve `relativePath` against `base` and return the absolute path only if it
|
|
* stays within `base` (after symlink resolution); otherwise return `null`.
|
|
*
|
|
* Prefer this over a bare `resolve()` followed by a separate `isSafePath()`
|
|
* check: collapsing the two into one call means a caller cannot resolve a
|
|
* project-relative path and then forget the containment guard — the gap that
|
|
* let the symlink-escape slip past several call sites historically.
|
|
*/
|
|
export function resolveWithinProject(base: string, relativePath: string): string | null {
|
|
const resolved = resolve(base, relativePath);
|
|
return isSafePath(base, resolved) ? resolved : null;
|
|
}
|
|
|
|
/** The project folder is gone, renamed or deleted while open; a write must not bring it back. */
|
|
export class ProjectRootMissingError extends Error {
|
|
constructor(readonly root: string) {
|
|
super(`Project folder not found: ${root}`);
|
|
this.name = "ProjectRootMissingError";
|
|
}
|
|
}
|
|
|
|
// By name, so a copy of this module bundled into another package still matches.
|
|
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 (folderGone(root)) throw new ProjectRootMissingError(root);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates `dir` below `root` one folder at a time, so a root moved away fails instead of reappearing.
|
|
* A `dir` outside `root` is created recursively, but only while `root` exists.
|
|
*/
|
|
export function mkdirWithinProject(root: string, dir: string): void {
|
|
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 });
|
|
return;
|
|
}
|
|
let path = resolve(root);
|
|
for (const part of inside.split(sep).filter(Boolean)) {
|
|
path = join(path, part);
|
|
try {
|
|
mkdirSync(path);
|
|
} catch (error) {
|
|
const code = (error as NodeJS.ErrnoException).code;
|
|
if (code === "ENOENT" && folderGone(root)) throw new ProjectRootMissingError(root);
|
|
if (code !== "EEXIST") throw error;
|
|
}
|
|
}
|
|
}
|