Skip to content

Commit 97fde27

Browse files
authored
fix(cli): pin Studio bundle, signature and runtime file reads (#3728)
* fix(cli): read Studio bundle files through checked descriptors * fix(cli): pin Studio signature and runtime artifact reads
1 parent 7a07ea9 commit 97fde27

5 files changed

Lines changed: 442 additions & 13 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { openSync, fstatSync, closeSync, statSync, readFileSync, constants } from "node:fs";
2+
3+
export function readBundleFile(filePath: string): Buffer<ArrayBuffer> | null {
4+
let fd: number;
5+
try {
6+
// Check named pipes without waiting for a writer to connect.
7+
fd = openSync(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
8+
} catch (error) {
9+
if (
10+
error instanceof Error &&
11+
"code" in error &&
12+
(error.code === "ENOENT" || error.code === "ENOTDIR")
13+
)
14+
return null;
15+
// Classify platform-specific directory/socket errors after a failed open.
16+
// No pathname read follows this check.
17+
if (!statSync(filePath, { throwIfNoEntry: false })?.isFile()) return null;
18+
throw error;
19+
}
20+
try {
21+
if (!fstatSync(fd).isFile()) return null;
22+
return readFileSync(fd);
23+
} finally {
24+
closeSync(fd);
25+
}
26+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import { tmpdir } from "node:os";
5+
import { loadRuntimeSource } from "./runtimeSource.js";
6+
7+
const hooks = vi.hoisted(() => ({
8+
dir: "",
9+
source: "",
10+
inlined: "",
11+
checked: () => {},
12+
beforeRead: () => {},
13+
active: new Set<number>(),
14+
opened: 0,
15+
}));
16+
vi.mock("@hyperframes/core", () => ({
17+
loadHyperframeRuntimeSource: () => hooks.source,
18+
getHyperframeRuntimeScript: () => hooks.inlined || null,
19+
}));
20+
vi.mock("node:path", async (importOriginal) => {
21+
const actual = await importOriginal<typeof path>();
22+
return {
23+
...actual,
24+
resolve: (...parts: string[]) => {
25+
const name = parts.at(-1) ?? "";
26+
if (hooks.dir && ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"].includes(name))
27+
return actual.join(hooks.dir, name);
28+
return actual.resolve(...parts);
29+
},
30+
};
31+
});
32+
vi.mock("node:fs", async (importOriginal) => {
33+
const actual = await importOriginal<typeof fs>();
34+
return {
35+
...actual,
36+
existsSync: (file: fs.PathLike) => {
37+
if (String(file).endsWith("entry.ts")) return true;
38+
const exists = actual.existsSync(file);
39+
if (exists) hooks.checked();
40+
return exists;
41+
},
42+
openSync: (file: fs.PathLike, flags: string | number) => {
43+
const fd = actual.openSync(file, flags);
44+
hooks.active.add(fd);
45+
hooks.opened++;
46+
return fd;
47+
},
48+
fstatSync: (fd: number) => {
49+
const stat = actual.fstatSync(fd);
50+
hooks.checked();
51+
return stat;
52+
},
53+
closeSync: (fd: number) => {
54+
actual.closeSync(fd);
55+
hooks.active.delete(fd);
56+
},
57+
readFileSync: (file: fs.PathOrFileDescriptor, encoding?: BufferEncoding) => {
58+
hooks.beforeRead();
59+
return encoding ? actual.readFileSync(file, encoding) : actual.readFileSync(file);
60+
},
61+
};
62+
});
63+
64+
describe("prebuilt runtime file reads", () => {
65+
beforeEach(() => {
66+
hooks.dir = fs.mkdtempSync(path.join(tmpdir(), "hf-runtime-read-"));
67+
hooks.opened = 0;
68+
hooks.active.clear();
69+
});
70+
afterEach(() => {
71+
hooks.checked = () => {};
72+
hooks.beforeRead = () => {};
73+
hooks.source = "";
74+
hooks.inlined = "";
75+
fs.rmSync(hooks.dir, { recursive: true, force: true });
76+
hooks.dir = "";
77+
});
78+
function expectClosed() {
79+
expect(hooks.opened).toBeGreaterThan(0);
80+
expect(hooks.active.size).toBe(0);
81+
}
82+
83+
it.each(["hyperframe-runtime.js", "hyperframe.runtime.iife.js"])(
84+
"reads checked %s despite replacement",
85+
async (name) => {
86+
const file = path.join(hooks.dir, name);
87+
fs.writeFileSync(file, "checked runtime");
88+
hooks.checked = () => {
89+
hooks.checked = () => {};
90+
fs.renameSync(file, path.join(hooks.dir, "original"));
91+
fs.writeFileSync(file, "replacement runtime");
92+
};
93+
expect(await loadRuntimeSource()).toBe("checked runtime");
94+
expectClosed();
95+
},
96+
);
97+
98+
it("preserves source, inline and artifact priority", async () => {
99+
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "first artifact");
100+
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
101+
hooks.source = "source";
102+
hooks.inlined = "inline";
103+
expect(await loadRuntimeSource()).toBe("source");
104+
hooks.source = "";
105+
expect(await loadRuntimeSource()).toBe("inline");
106+
expect(hooks.opened).toBe(0);
107+
hooks.inlined = "";
108+
expect(await loadRuntimeSource()).toBe("first artifact");
109+
expectClosed();
110+
});
111+
112+
it("preserves an empty first artifact", async () => {
113+
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "");
114+
fs.writeFileSync(path.join(hooks.dir, "hyperframe.runtime.iife.js"), "second artifact");
115+
expect(await loadRuntimeSource()).toBe("");
116+
expectClosed();
117+
});
118+
119+
it("returns null when artifacts are absent", async () => {
120+
expect(await loadRuntimeSource()).toBeNull();
121+
expect(hooks.active.size).toBe(0);
122+
});
123+
124+
it.each(["stat", "read"])("closes an artifact after %s failure", async (step) => {
125+
fs.writeFileSync(path.join(hooks.dir, "hyperframe-runtime.js"), "bytes");
126+
const fail = () => {
127+
throw new Error("Injected artifact failure");
128+
};
129+
if (step === "stat") hooks.checked = fail;
130+
else hooks.beforeRead = fail;
131+
await expect(loadRuntimeSource()).rejects.toThrow("Injected artifact failure");
132+
expectClosed();
133+
});
134+
});

packages/cli/src/server/runtimeSource.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from "node:crypto";
2-
import { existsSync, readFileSync } from "node:fs";
2+
import { existsSync } from "node:fs";
3+
import { readBundleFile } from "./readBundleFile.js";
34
import { resolve, dirname } from "node:path";
45

56
const ARTIFACT_NAMES = ["hyperframe-runtime.js", "hyperframe.runtime.iife.js"];
@@ -73,7 +74,8 @@ function readPrebuiltArtifact(): string | null {
7374
function readFromDir(dir: string): string | null {
7475
for (const name of ARTIFACT_NAMES) {
7576
const path = resolve(dir, name);
76-
if (existsSync(path)) return readFileSync(path, "utf-8");
77+
const content = readBundleFile(path);
78+
if (content !== null) return content.toString("utf-8");
7779
}
7880
return null;
7981
}

0 commit comments

Comments
 (0)