Skip to content

fix: escape NUL bytes in HFMASK regex (Bun blank renders) + Windows junction for studio preview links#2140

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/nul-bytes-and-win-symlink
Jul 10, 2026
Merged

fix: escape NUL bytes in HFMASK regex (Bun blank renders) + Windows junction for studio preview links#2140
miguel-heygen merged 2 commits into
mainfrom
fix/nul-bytes-and-win-symlink

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Fixes #2139 (both defects).

1. Raw NUL bytes in the HFMASK restore regex (blank renders under Bun <= 1.3.11)

maskInertRegions in timingCompiler.ts had literal 0x00 bytes typed into the token template and the restore regex (introduced in #1940). That made the source file binary to git and shipped two raw NULs into dist/cli.js. Bun's transpiler (<= 1.3.11) rewrites raw NULs in regex literals into literal replacement-character text, so restore matched nothing, masked <style>/<script> regions were silently dropped, the injected player never initialized, and bunx hyperframes render produced blank white frames showing HFMASK tokens.

Fix: the delimiters are now \u0000 escapes, which survive any transpile/minify layer. Added a byte-level regression test: behavior is identical under Node, so only a byte check catches a reintroduction (lint could not parse the raw byte either, which is how it slipped through).

Verified:

  • published 0.7.49 bundle: 2 raw NULs, both in the restore regex; rebuilt bundle: 0
  • reproduced the corruption on Bun 1.3.5 (restore no-ops, HFMASK tokens survive) and the reported source rewrite on 1.3.14
  • mask/restore round-trip through the rebuilt @hyperframes/core dist passes under Node 22, Bun 1.3.5, and Bun 1.3.14
  • core suite 1182/1182 green

2. preview EPERM on Windows without Developer Mode

linkProjectIntoStudioData was the one symlink site with no Windows guard: symlinkSync(dir, path, "dir") needs Developer Mode or elevation, so local-studio preview (and dev, same helper) died with EPERM for default-configured users.

Fix: use an NTFS junction on win32. Junctions need no privilege, work for directories, and keep the live write-back the studio depends on (a cpSync fallback would decouple the studio's copy from the real project, so it was deliberately not used). Node normalizes junction targets to absolute paths, and libuv reports junctions as symlinks, so the existing stale-link cleanup and exit-time unlink keep working.

Not reproducible on macOS; validated against the stack trace and repro steps in the issue, which point at exactly this call site.

Raw 0x00 bytes in the maskInertRegions token and restore regex made
timingCompiler.ts binary to git and shipped raw NULs into dist/cli.js.
Bun's transpiler (<= 1.3.11) corrupts raw NULs in regex literals into
literal backslash-uFFFD text, so restore never matched: every masked
<style>/<script> region was dropped, the player never initialized, and
bunx renders produced blank white frames showing HFMASK tokens.

Use \u0000 escapes instead, which survive any transpile layer, and add
a byte-level regression test (behavior is identical under Node, so only
a byte check catches this).

Fixes the first half of #2139.
linkProjectIntoStudioData called symlinkSync(dir, path, "dir"), which
needs Developer Mode or elevation on Windows, so preview and dev in
local-studio mode died with EPERM for default-configured users.
Junctions need no privilege, work for directories, and keep the live
write-back the studio depends on (a copy fallback would decouple the
studio from the real project). Covers both preview and dev, which share
the helper.

Fixes the second half of #2139.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

GitHub renders timingCompiler.ts as "Binary file not shown" because the before-side blob (on main) contains the raw 0x00 bytes this PR removes; the classification comes from the old contents, so it cannot be overridden for this diff. After merge the file is text and future diffs render normally.

The actual change, with control bytes shown as ^@ (cat -v):

diff --git a/packages/core/src/compiler/timingCompiler.ts b/packages/core/src/compiler/timingCompiler.ts
index eb55a112a..b5c3a06fe 100644
--- a/packages/core/src/compiler/timingCompiler.ts
+++ b/packages/core/src/compiler/timingCompiler.ts
@@ -86,15 +86,18 @@ function injectAttr(tag: string, attr: string, value: string): string {
 const INERT_REGION_RE =
   /<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;
 
+// The NUL delimiters must stay as \u0000 escapes: raw 0x00 bytes make this file
+// binary to git and are corrupted by Bun's transpiler when bundled (issue #2139).
 function maskInertRegions(html: string): { masked: string; restore: (s: string) => string } {
   const stash: string[] = [];
   const masked = html.replace(INERT_REGION_RE, (region) => {
-    const token = `^@HFMASK${stash.length}^@`;
+    const token = `\u0000HFMASK${stash.length}\u0000`;
     stash.push(region);
     return token;
   });
   const restore = (s: string): string =>
-    s.replace(/^@HFMASK(\d+)^@/g, (_, i) => stash[Number(i)] ?? "");
+    // oxlint-disable-next-line no-control-regex -- NUL cannot appear in HTML, which is what makes it a safe mask delimiter
+    s.replace(/\u0000HFMASK(\d+)\u0000/g, (_, i) => stash[Number(i)] ?? "");
   return { masked, restore };
 }
 

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Verified — both fixes byte-clean, ship it.

Two small defects, two surgical excisions, and the receipts hold up under the microscope.


Fix 1 — HFMASK escapes (timingCompiler.ts)

The git diff renders empty here because the file was previously binary-classified — the true evidence lives at the byte level. Fetched both blobs and counted:

  • Parent (6152437d): 4 raw 0x00 bytes at offsets 3689, 3711, 3819, 3831 — the two token delimiters at construction (${...HFMASK${n}${...}) and the two in the restore regex.
  • Head (ed8696be): 0 raw 0x00 bytes.
  • Both sites now use string/regex escapes — semantically equivalent to \x00, safe under Bun's transpiler, and git renders the file as text again.

Symmetry holds — line 94 constructs `�HFMASK${stash.length}�`, line 100 restores with /�HFMASK(\d+)�/g. The oxlint-disable-next-line no-control-regex sits exactly where it must, and the tombstone comment at lines 89–90 explicitly names issue #2139 as the trap to avoid on future edits. Mask + restore live in one function scope, one run — no persisted HFMASK fixtures anywhere in the repo (searched: single hit, this file). So no fixture-format skew to worry about.

The regression test earns its keep:

const src = readFileSync(join(dirname(testPath), "timingCompiler.ts"), "latin1");
expect(src.includes("\x00")).toBe(false);

latin1 is the byte-preserving encoding (0x00–0xFF ↔ U+0000–U+00FF, 1:1), so a reintroduced 0x00 byte survives the read as \x00 in the string and the assertion fires. Byte-safe by construction — a UTF-8 read would risk mangling the check depending on surrounding sequences; this doesn't.

Fix 2 — Windows junction (preview.ts)

Ternary sits at the sole symlink-creation site in linkProjectIntoStudioData (line 687):

symlinkSync(dir, symlinkPath, process.platform === "win32" ? "junction" : "dir");

Cleanup at lines 676–678 uses lstatSync(...).isSymbolicLink() + readlinkSync + unlinkSync — libuv reports NTFS junctions as symlinks on all three, so the existing stale-link path and the on-exit unlinkSync (line 699) work unchanged. Junctions are directory-only and unprivileged; dir is a project root resolved via resolveProject(rawArg).dir (line 210), always absolute, always a directory — none of the junction gotchas bite here.

Blast radius audit — no siblings. Repo-wide search for symlinkSync inside packages/cli:

  • packages/cli/src/commands/preview.ts — this PR.
  • packages/cli/src/utils/skillsMirror.ts:64–68 — already Windows-guarded, forks to cpSync copy under win32. No latent EPERM here.

The claim that this was the one un-guarded site holds.

One residual seam worth naming, not blocking: if a Windows user keeps their project on a different volume than packages/studio/data/projects (e.g. project on D:, repo on C:), junction creation fails with EXDEV — junctions are single-volume. Vanishingly rare for HF users, and it degrades to a clear filesystem error rather than a silent blank render, so O2 at worst. Flag for the follow-up if a real user ever surfaces it.


Verdict: clear to ship. Receipts held: 4 → 0 raw NULs, symmetric escapes, byte-safe regression guard, ternary at the sole site, no un-guarded siblings.

R1 by Via

@miguel-heygen miguel-heygen merged commit 718c67b into main Jul 10, 2026
51 of 52 checks passed
@miguel-heygen miguel-heygen deleted the fix/nul-bytes-and-win-symlink branch July 10, 2026 20:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants