Skip to content

Commit de4062a

Browse files
authored
fix: create temp dirs with mkdtemp, not a name built from Date.now() (#3241)
* fix: create temp dirs with mkdtemp, not a name built from Date.now() Closes nine open `js/insecure-temporary-file` alerts — the technically correct ones. An audit of all 29 open alerts for that rule split them three ways: - 19 false positives: the write lands inside a directory the caller already made with `mkdtempSync`, and CodeQL's dataflow reaches `tmpdir()` without seeing the mkdtemp in between. - 1 mitigated: `fontCompression.ts` writes with `flag: "wx"` and only takes the tmpdir branch inside Lambda, where /tmp is single-tenant. - 9 real, and these are them. A name built from `Date.now()` under the shared temp dir, followed by `mkdirSync`, is guessable to the millisecond AND leaves a window between choosing the name and creating it, so on a shared machine another user can pre-create or symlink the path first. `mkdtempSync` closes both halves: it picks the random suffix and creates the directory 0700 in one syscall. Same shape, one line shorter, and the alerts go away rather than being dismissed. Six sites in `normalize.test.ts` (its `mkdirSync` import goes with them), one in `generate-catalog-previews.ts` — that single construction accounted for three alerts, since the other two were writes into the directory it made. No shared helper. `mkdtempSync` is already the stdlib primitive for exactly this, and the two callers live in different packages, so a wrapper would need a home in core to serve one CLI test and one build script — more indirection than the line it saves. Deliberately not touching the other 20: excluding the rule repo-wide would hide this class of bug from future code, which is the reason these are fixed rather than silenced. * fix: track the wav temp dir for cleanup and finish the mkdtemp sweep The wav helper pushed the file path into `dirs`, so `afterEach` removed `tone.wav` and left the directory it had just made — four per suite run. Push the directory and derive the file path from it. Measured: the old code leaks 4 directories per run, the new code leaks 0. Three sites still built a predictable name and then created it. CodeQL never flagged them — its dataflow reaches the template preview writes through a `readdir` walk and does not connect them back to the `tmpdir()` root — so the alert list was narrower than the pattern, and closing only the alerts would turn the rule green while the shape survived where nothing would re-flag it. `generate-template-previews.ts` is the near-twin of the file this change started from, and the other two are producer dev entry points. All three use the path only through the variable, so the random suffix changes nothing. Catalog previews now call the existing `createCatalogPreviewTempDir` instead of repeating its body. That test was in no runner, so it pinned uniqueness and mode 0700 on a function nothing called; adding it to `test:scripts` alongside a real caller makes it load-bearing. The rationale for the primitive moves to the helper, which is now the only place it lives. * ci: re-run catalog previews when the temp-dir module changes Routing the renderer through `createCatalogPreviewTempDir` made that module part of its runtime path, and the workflow already states the rule for the sibling case: a module the renderer imports has to appear in the trigger, or a change to it alone never re-runs the job that exercises it. Add it to the `paths:` filter and to the renderer canary, so a PR touching only the temp-dir allocation still renders both shape canaries. Verified against this branch's own range: the previous argument list does not report the file, so a helper-only PR was invisible to both checks.
1 parent 12fd6d9 commit de4062a

8 files changed

Lines changed: 31 additions & 28 deletions

File tree

.github/workflows/catalog-previews.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ on:
1515
- "registry/blocks/**"
1616
- "registry/components/**"
1717
- "scripts/generate-catalog-previews.ts"
18-
# The containment module the renderer imports. Without it a change to
19-
# path-traversal defence alone never re-runs the job that exercises it.
18+
# Modules the renderer imports. Without them a change to path-traversal
19+
# defence or temp-directory allocation alone never re-runs the job that
20+
# exercises it.
2021
- "scripts/registry-target-paths.mjs"
22+
- "scripts/catalog-preview-temp.ts"
2123
- ".github/workflows/catalog-previews.yml"
2224

2325
concurrency:
@@ -87,7 +89,8 @@ jobs:
8789
# timeline at body level (rendered directly). Getting that wrong is
8890
# silent — the wrong-shaped block renders blank, not red.
8991
RENDERER_CHANGED=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD \
90-
-- scripts/generate-catalog-previews.ts scripts/registry-target-paths.mjs)
92+
-- scripts/generate-catalog-previews.ts scripts/registry-target-paths.mjs \
93+
scripts/catalog-preview-temp.ts)
9194
if [ -n "$RENDERER_CHANGED" ]; then
9295
CHANGED_ITEMS=$(printf '%s\n' $CHANGED_ITEMS \
9396
code-snippet-visual-studio-dark code-snippet-apple-terminal-pro | sort -u)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"player:perf": "bun run --filter @hyperframes/player perf",
4949
"format:check": "oxfmt --check .",
5050
"knip": "knip",
51-
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/",
51+
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs && vitest run scripts/catalog/",
5252
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
5353
"test:skills": "node --test 'skills/**/*.test.mjs'",
5454
"generate:previews": "tsx scripts/generate-template-previews.ts",

packages/cli/src/whisper/normalize.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, afterEach } from "vitest";
2-
import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
2+
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import {
@@ -14,8 +14,7 @@ import {
1414
import { detectSpeechOnset } from "./transcribe.js";
1515

1616
function tmpFile(name: string, content: string): string {
17-
const dir = join(tmpdir(), `hf-normalize-test-${Date.now()}`);
18-
mkdirSync(dir, { recursive: true });
17+
const dir = mkdtempSync(join(tmpdir(), "hf-normalize-test-"));
1918
dirs.push(dir);
2019
const path = join(dir, name);
2120
writeFileSync(path, content);
@@ -478,8 +477,7 @@ describe("whisper-cpp zero-duration interpolation", () => {
478477

479478
describe("patchCaptionHtml", () => {
480479
it("replaces const script = [] in HTML files", () => {
481-
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
482-
mkdirSync(dir, { recursive: true });
480+
const dir = mkdtempSync(join(tmpdir(), "hf-patch-test-"));
483481
dirs.push(dir);
484482

485483
const html = `<html><body><script>
@@ -501,8 +499,7 @@ describe("patchCaptionHtml", () => {
501499
});
502500

503501
it("replaces const TRANSCRIPT = [] variant", () => {
504-
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
505-
mkdirSync(dir, { recursive: true });
502+
const dir = mkdtempSync(join(tmpdir(), "hf-patch-test-"));
506503
dirs.push(dir);
507504

508505
const html = `<script>const TRANSCRIPT = [];</script>`;
@@ -516,8 +513,7 @@ describe("patchCaptionHtml", () => {
516513
});
517514

518515
it("does not modify HTML files without matching script patterns", () => {
519-
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
520-
mkdirSync(dir, { recursive: true });
516+
const dir = mkdtempSync(join(tmpdir(), "hf-patch-test-"));
521517
dirs.push(dir);
522518

523519
const html = `<html><body><script>console.log("hello");</script></body></html>`;
@@ -530,8 +526,7 @@ describe("patchCaptionHtml", () => {
530526
});
531527

532528
it("skips empty word arrays", () => {
533-
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
534-
mkdirSync(dir, { recursive: true });
529+
const dir = mkdtempSync(join(tmpdir(), "hf-patch-test-"));
535530
dirs.push(dir);
536531

537532
const html = `<script>const script = [];</script>`;
@@ -572,9 +567,10 @@ describe("detectSpeechOnset", () => {
572567
const amplitude = energyFn(t);
573568
buf.writeInt16LE(Math.round(amplitude * 32767), 44 + i * 2);
574569
}
575-
const path = join(tmpdir(), `hf-wav-test-${Date.now()}-${Math.floor(Math.random() * 1e6)}.wav`);
570+
const dir = mkdtempSync(join(tmpdir(), "hf-wav-test-"));
571+
dirs.push(dir);
572+
const path = join(dir, "tone.wav");
576573
writeFileSync(path, buf);
577-
dirs.push(path);
578574
return path;
579575
}
580576

packages/producer/src/benchmark.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
writeFileSync,
2626
existsSync,
2727
mkdirSync,
28+
mkdtempSync,
2829
cpSync,
2930
rmSync,
3031
} from "node:fs";
@@ -204,8 +205,7 @@ async function runBenchmark(): Promise<void> {
204205
console.log(` Run ${r + 1}/${runs}...`);
205206

206207
// Copy src to temp dir for isolation
207-
const tmpRoot = join(tmpdir(), `benchmark-${fixture.id}-${Date.now()}`);
208-
mkdirSync(tmpRoot, { recursive: true });
208+
const tmpRoot = mkdtempSync(join(tmpdir(), `benchmark-${fixture.id}-`));
209209
cpSync(join(fixture.dir, "src"), join(tmpRoot, "src"), { recursive: true });
210210

211211
const projectDir = join(tmpRoot, "src");

packages/producer/src/transparency-test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
*/
2121

2222
import { strict as assert } from "node:assert";
23-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
23+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
2424
import { tmpdir } from "node:os";
2525
import { dirname, join, resolve } from "node:path";
2626
import { fileURLToPath } from "node:url";
@@ -309,8 +309,7 @@ async function main(): Promise<void> {
309309
if (!existsSync(SHADER_FIXTURE_SRC) || !existsSync(SHADER_GOLDEN)) {
310310
throw new Error(`Shader fixture or golden missing: ${SHADER_FIXTURE_DIR}`);
311311
}
312-
const workRoot = join(tmpdir(), `hf-transparency-${process.pid}-${Date.now()}`);
313-
mkdirSync(workRoot, { recursive: true });
312+
const workRoot = mkdtempSync(join(tmpdir(), "hf-transparency-"));
314313
const keepWork = process.env.KEEP_TEMP === "1";
315314
console.log(`work dir: ${workRoot}${keepWork ? " (KEEP_TEMP=1)" : ""}`);
316315

scripts/catalog-preview-temp.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { mkdtempSync } from "node:fs";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
44

5-
/** Atomically allocate an owner-only preview directory under the OS temp root. */
5+
/**
6+
* Atomically allocate an owner-only preview directory under the OS temp root.
7+
*
8+
* `mkdtemp` rather than a name built from `Date.now()`: it picks the random
9+
* suffix and creates the directory 0700 in one syscall, so nothing can
10+
* pre-create or symlink the path between choosing the name and making it.
11+
*/
612
export function createCatalogPreviewTempDir(itemName: string): string {
713
return mkdtempSync(join(tmpdir(), `hf-catalog-${itemName}-`));
814
}

scripts/generate-catalog-previews.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ import {
3131
} from "node:fs";
3232
import { execFileSync } from "node:child_process";
3333
import { join, resolve, dirname } from "node:path";
34-
import { tmpdir } from "node:os";
3534
import { fileURLToPath } from "node:url";
35+
import { createCatalogPreviewTempDir } from "./catalog-preview-temp.js";
3636
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
3737
import {
3838
captureFrame,
@@ -170,8 +170,7 @@ export async function prepareProjectDir(
170170
item: CatalogItem,
171171
options: PrepareOptions = {},
172172
): Promise<string> {
173-
const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`);
174-
mkdirSync(tmpDir, { recursive: true });
173+
const tmpDir = createCatalogPreviewTempDir(item.name);
175174
cpSync(item.sourceDir, tmpDir, { recursive: true });
176175
mirrorRegistryTargets(tmpDir);
177176

scripts/generate-template-previews.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
writeFileSync,
2222
existsSync,
2323
mkdirSync,
24+
mkdtempSync,
2425
cpSync,
2526
rmSync,
2627
} from "node:fs";
@@ -126,8 +127,7 @@ function discoverTemplates(only: string | null): string[] {
126127
}
127128

128129
function prepareTemplateDir(templateId: string): string {
129-
const tmpDir = join(tmpdir(), `hf-preview-${templateId}-${Date.now()}`);
130-
mkdirSync(tmpDir, { recursive: true });
130+
const tmpDir = mkdtempSync(join(tmpdir(), `hf-preview-${templateId}-`));
131131
const src = resolveTemplateDir(templateId);
132132
if (!src) throw new Error(`Template directory not found for "${templateId}"`);
133133
cpSync(src, tmpDir, { recursive: true });

0 commit comments

Comments
 (0)