Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/windows-render.yml
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,11 @@ jobs:
shell: pwsh
run: bunx vitest run packages/producer/src/services/hyperframeLint.file-race.test.ts --maxWorkers=2

- name: Verify catalog source reads on Windows
if: matrix.lane == 'studio-engine-cli'
shell: pwsh
run: bunx vitest run scripts/catalog/catalog-payload-assets.file-race.test.ts --maxWorkers=2

- name: Run runtime contract test
if: matrix.runtime_contract
shell: pwsh
Expand Down
89 changes: 77 additions & 12 deletions scripts/catalog-payload-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@
*/

import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
realpathSync,
openSync,
fstatSync,
closeSync,
constants,
} from "node:fs";
import { extname, join, resolve, relative, isAbsolute, sep } from "node:path";

export const MIME_TYPES: Record<string, string> = {
".png": "image/png",
Expand Down Expand Up @@ -127,6 +139,49 @@ export interface AssetResult {
unresolved: string[];
}

function isWithin(root: string, filePath: string): boolean {
const rel = relative(root, filePath);
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}

/** Read one checked file from the prepared project, including internal links. */
function readProjectFile(root: string, filePath: string): Buffer<ArrayBuffer> | null {
if (!isWithin(root, filePath)) return null;
let source: string;
try {
source = realpathSync(filePath);
if (!isWithin(realpathSync(root), source)) return null;
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
)
return null;
throw error;
}
let fd: number;
try {
// Nonblocking mode lets fstat reject named pipes without waiting for a writer.
fd = openSync(source, constants.O_RDONLY | constants.O_NONBLOCK);
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error.code === "ENOENT" || error.code === "ENOTDIR")
)
return null;
if (!statSync(source, { throwIfNoEntry: false })?.isFile()) return null;
throw error;
}
try {
if (!fstatSync(fd).isFile()) return null;
return readFileSync(fd);
} finally {
closeSync(fd);
}
}

export interface AssetTarget {
/** Directory shared by every item, so one font is stored once. */
dir: string;
Expand Down Expand Up @@ -164,8 +219,8 @@ export function processAssets(html: string, projectDir: string, target: AssetTar

// A composition reaching outside its own directory would pull an arbitrary
// file from the build machine into a published payload.
const contained = source === root || source.startsWith(`${root}/`);
if (!contained || !existsSync(source) || !statSync(source).isFile()) {
const bytes = readProjectFile(root, source);
if (bytes === null) {
// A name a script passed around that turned out not to be a file is just
// a string; only a reference we are sure about counts as a broken one.
if (strict) unresolved.push(ref);
Expand All @@ -179,7 +234,6 @@ export function processAssets(html: string, projectDir: string, target: AssetTar
continue;
}

const bytes = readFileSync(source);
if (HOSTED_EXTENSIONS.has(ext)) {
const name = `${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}${ext}`;
const dest = join(target.dir, name);
Expand Down Expand Up @@ -248,19 +302,27 @@ export function externalizeDataUris(
}

/** Copy a directory's publishable files into `destDir`, flattening one level. */
function walkInto(from: string, rel: string, destDir: string, onCopy: () => void): void {
function walkInto(
root: string,
from: string,
rel: string,
destDir: string,
onCopy: () => void,
): void {
for (const entry of readdirSync(from, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
const childFrom = join(from, entry.name);
if (entry.isDirectory()) {
walkInto(childFrom, childRel, destDir, onCopy);
walkInto(root, childFrom, childRel, destDir, onCopy);
continue;
}
if (!HOSTED_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
const bytes = readProjectFile(root, childFrom);
if (bytes === null) continue;
const to = join(destDir, childRel);
mkdirSync(join(to, ".."), { recursive: true });
writeFileSync(to, readFileSync(childFrom));
writeFileSync(to, bytes);
onCopy();
}
}
Expand Down Expand Up @@ -319,9 +381,11 @@ export function hostItemDirectory(projectDir: string, destDir: string, urlBase:
continue;
}
if (!HOSTED_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
const bytes = readProjectFile(projectDir, childFrom);
if (bytes === null) continue;
const to = join(destDir, childRel);
mkdirSync(join(to, ".."), { recursive: true });
writeFileSync(to, readFileSync(childFrom));
writeFileSync(to, bytes);
copied += 1;
}
};
Expand All @@ -339,7 +403,7 @@ export function hostItemDirectory(projectDir: string, destDir: string, urlBase:
// working, and the files are content-identical either way.
const downloads = join(projectDir, "_downloads");
if (existsSync(downloads) && statSync(downloads).isDirectory()) {
walkInto(downloads, "", destDir, () => (copied += 1));
walkInto(projectDir, downloads, "", destDir, () => (copied += 1));
}

return copied > 0 ? urlBase : "";
Expand Down Expand Up @@ -376,8 +440,9 @@ export function inlineMountedComposition(html: string, projectDir: string): stri
(whole, quote: string, ref: string) => {
if (/^(https?:|data:)/i.test(ref)) return whole;
const source = resolve(projectDir, ref.replace(/^\.\//, "").split(/[?#]/)[0] ?? ref);
if (!source.startsWith(resolve(projectDir)) || !existsSync(source)) return whole;
const encoded = readFileSync(source).toString("base64");
const bytes = readProjectFile(resolve(projectDir), source);
if (bytes === null) return whole;
const encoded = bytes.toString("base64");
return `data-composition-src=${quote}data:text/html;base64,${encoded}${quote}`;
},
);
Expand Down
196 changes: 196 additions & 0 deletions scripts/catalog/catalog-payload-assets.file-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
processAssets,
inlineMountedComposition,
hostItemDirectory,
} from "../catalog-payload-assets.ts";

const hooks = vi.hoisted(() => {
const state: {
target: string;
swap?: () => void;
readCount: number;
failStat: boolean;
failRead: boolean;
fds: Map<number, string>;
} = {
target: "",
readCount: 1,
failStat: false,
failRead: false,
fds: new Map(),
};
return state;
});
vi.mock("node:fs", async (importOriginal) => {
const fs = await importOriginal<typeof import("node:fs")>();
return {
...fs,
openSync: (...args: Parameters<typeof fs.openSync>) => {
const fd = fs.openSync(...args);
hooks.fds.set(fd, String(args[0]));
return fd;
},
fstatSync: (...args: Parameters<typeof fs.fstatSync>) => {
if (hooks.failStat) throw new Error("stat failure");
return fs.fstatSync(...args);
},
readFileSync: (...args: Parameters<typeof fs.readFileSync>) => {
const path = typeof args[0] === "number" ? hooks.fds.get(args[0]) : String(args[0]);
if (
path &&
hooks.target &&
fs.realpathSync(path) === fs.realpathSync(hooks.target) &&
--hooks.readCount === 0
) {
const swap = hooks.swap;
hooks.swap = undefined;
swap?.();
if (hooks.failRead) throw new Error("read failure");
}
return fs.readFileSync(...args);
},
closeSync: (fd: number) => {
fs.closeSync(fd);
hooks.fds.delete(fd);
},
};
});
const fs = await vi.importActual<typeof import("node:fs")>("node:fs");
let root: string;
let project: string;
let out: { dir: string; urlBase: string };
beforeEach(() => {
root = fs.mkdtempSync(join(tmpdir(), "hf-catalog-security-"));
project = join(root, "project");
fs.mkdirSync(project);
out = { dir: join(root, "output"), urlBase: "/assets" };
Object.assign(hooks, {
target: "",
swap: undefined,
readCount: 1,
failStat: false,
failRead: false,
});
});
afterEach(() => {
const leaked = [...hooks.fds.keys()];
for (const fd of leaked) fs.closeSync(fd);
hooks.fds.clear();
fs.rmSync(root, { recursive: true, force: true });
expect(leaked).toEqual([]);
});
function file(name: string, bytes = "checked") {
const path = join(project, name);
fs.mkdirSync(dirname(path), { recursive: true });
fs.writeFileSync(path, bytes);
return path;
}
function arm(path: string) {
hooks.target = path;
hooks.swap = () => {
fs.renameSync(path, join(root, "original"));
fs.writeFileSync(path, "unchecked");
};
}

describe("catalog source reads", () => {
it.each(["png", "glb"])("uses checked .%s bytes for hosted and inlined references", (ext) => {
arm(file(`asset.${ext}`));
const result = processAssets(`<img src="asset.${ext}">`, project, out);
if (ext === "png") {
const digest = createHash("sha256").update("checked").digest("hex").slice(0, 16);
expect(result.html).toBe(`<img src="/assets/${digest}.png">`);
expect(fs.readFileSync(join(out.dir, `${digest}.png`), "utf8")).toBe("checked");
} else expect(result.html).toContain(Buffer.from("checked").toString("base64"));
expect(hooks.swap).toBeUndefined();
});
it("uses checked bytes for mounted composition HTML", () => {
arm(file("clip.html"));
expect(inlineMountedComposition('<div data-composition-src="clip.html">', project)).toContain(
Buffer.from("checked").toString("base64"),
);
expect(hooks.swap).toBeUndefined();
});
it.each([false, true])(
"uses checked bytes in directory copies (download mirror: %s)",
(mirror) => {
arm(file(mirror ? "_downloads/font.woff2" : "font.woff2"));
if (mirror) hooks.readCount = 2;
expect(hostItemDirectory(project, out.dir, "/item/")).toBe("/item/");
expect(fs.readFileSync(join(out.dir, "font.woff2"), "utf8")).toBe("checked");
expect(hooks.swap).toBeUndefined();
},
);
it.each(["png", "glb", "html"])("does not publish an external .%s symlink target", (ext) => {
const outside = join(root, `outside.${ext}`);
fs.writeFileSync(outside, "secret");
fs.symlinkSync(outside, join(project, `linked.${ext}`), "file");
if (ext === "html") {
const html = '<div data-composition-src="linked.html">';
expect(inlineMountedComposition(html, project)).toBe(html);
} else {
const result = processAssets(`<img src="linked.${ext}">`, project, out);
expect(result.unresolved).toEqual([`linked.${ext}`]);
expect(result.hosted + result.inlined).toBe(0);
}
expect(fs.existsSync(out.dir)).toBe(false);
});
it("does not mirror a downloads directory linked outside the project", () => {
const outside = join(root, "outside");
fs.mkdirSync(outside);
fs.writeFileSync(join(outside, "secret.png"), "secret");
fs.symlinkSync(
outside,
join(project, "_downloads"),
process.platform === "win32" ? "junction" : "dir",
);
expect(hostItemDirectory(project, out.dir, "/item/")).toBe("");
expect(fs.existsSync(out.dir)).toBe(false);
});
it("keeps internal file links and a symlinked project root working", () => {
const target = file("asset.glb");
fs.symlinkSync(target, join(project, "linked.glb"), "file");
const alias = join(root, "alias");
fs.symlinkSync(project, alias, process.platform === "win32" ? "junction" : "dir");
const result = processAssets('<img src="linked.glb">', alias, out);
expect(result.inlined).toBe(1);
expect(result.unresolved).toEqual([]);
});
it("rejects sibling-prefix mounted composition escapes", () => {
const sibling = join(root, "project-other");
fs.mkdirSync(sibling);
fs.writeFileSync(join(sibling, "clip.html"), "secret");
const html = '<div data-composition-src="../project-other/clip.html">';
expect(inlineMountedComposition(html, project)).toBe(html);
});
it("preserves missing/probable reference and empty-file behavior", () => {
fs.mkdirSync(join(project, "dir.png"));
file("empty.glb", "");
const result = processAssets(
'<img src="dir.png"><img src="missing.png"><script>load("probable.png")</script><img src="empty.glb">',
project,
out,
);
expect(result.unresolved).toEqual(["dir.png", "missing.png"]);
expect(result.inlined).toBe(1);
expect(result.html).toContain('src="data:model/gltf-binary;base64,"');
});
it.skipIf(process.platform === "win32")("rejects a FIFO without waiting for a writer", () => {
execFileSync("mkfifo", [join(project, "pipe.png")]);
expect(processAssets('<img src="pipe.png">', project, out).unresolved).toEqual(["pipe.png"]);
});
it.each(["stat", "read"])("closes the source descriptor after %s failure", (failure) => {
hooks.target = file("asset.png");
hooks.failStat = failure === "stat";
hooks.failRead = failure === "read";
expect(() => processAssets('<img src="asset.png">', project, out)).toThrow(
`${failure} failure`,
);
expect(hooks.fds.size).toBe(0);
});
});
Loading