From 4e609e8e089949479410a9f9a69025ef7e1c2d87 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:59:14 +0000 Subject: [PATCH 1/3] util: styleText handles inspect.colors mutation after cache; getCallSites captures Error styleText() caches inspect.colors on first call but validated unknown format strings against the live inspect.colors, so a color added after the cache was populated passed validation then dereferenced an undefined cache entry. Fall back to the live inspect.colors entry on cache miss and populate the cache, restoring the behavior from before the cache was introduced. getCallSites() read Error via the global binding, so replacing globalThis.Error broke it. Capture Error at module load like child_process.ts already does. --- src/js/node/util.ts | 32 ++++++++++++++++------- test/js/node/util/util.test.js | 48 +++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/js/node/util.ts b/src/js/node/util.ts index b00362b6e81f..54ac6e735f42 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -20,6 +20,7 @@ const parseEnv = $newRustFunction("node_util_binding.rs", "parseEnv", 1); const NumberIsSafeInteger = Number.isSafeInteger; const ObjectKeys = Object.keys; const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; +var Error = globalThis.Error; const { uncurryThis, SafeMap } = require("internal/primordials"); const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec); @@ -245,6 +246,17 @@ function getHexStyleCache() { return hexStyleCache; } +function buildStyleEntry(codes) { + const openNum = codes[0]; + const closeNum = codes[1]; + return { + __proto__: null, + openSeq: kEscape + openNum + kEscapeEnd, + closeSeq: kEscape + closeNum + kEscapeEnd, + keepClose: openNum === kDimCode || openNum === kBoldCode, + }; +} + function getStyleCache() { if (styleCache === undefined) { styleCache = { __proto__: null }; @@ -252,14 +264,7 @@ function getStyleCache() { for (const key of ObjectGetOwnPropertyNames(colors)) { const codes = colors[key]; if (codes) { - const openNum = codes[0]; - const closeNum = codes[1]; - styleCache[key] = { - __proto__: null, - openSeq: kEscape + openNum + kEscapeEnd, - closeSeq: kEscape + closeNum + kEscapeEnd, - keepClose: openNum === kDimCode || openNum === kBoldCode, - }; + styleCache[key] = buildStyleEntry(codes); } } } @@ -388,9 +393,16 @@ function styleText(format, text, options) { continue; } - const style = cache[key]; + let style = cache[key]; if (style === undefined) { - validateOneOf(key, "format", ObjectGetOwnPropertyNames(inspect.colors)); + // inspect.colors is user-mutable; a key added after the cache was + // populated is looked up live and cached on first use. + const codes = inspect.colors[key]; + if (codes == null) { + validateOneOf(key, "format", ObjectGetOwnPropertyNames(inspect.colors)); + } + style = buildStyleEntry(codes); + cache[key] = style; } openCodes += style.openSeq; closeCodes = style.closeSeq + closeCodes; diff --git a/test/js/node/util/util.test.js b/test/js/node/util/util.test.js index 7689c1230321..d26fd3e09f1b 100644 --- a/test/js/node/util/util.test.js +++ b/test/js/node/util/util.test.js @@ -23,7 +23,7 @@ import assert from "assert"; import { describe, expect, it } from "bun:test"; -import "harness"; +import { bunEnv, bunExe } from "harness"; import util from "util"; // const context = require('vm').runInNewContext; // TODO: Use a vm polyfill @@ -432,6 +432,32 @@ describe("util", () => { assert.strictEqual(util.styleText("red", "test", { validateStream: false }), "\u001b[31mtest\u001b[39m"); }); + // inspect.colors is user-mutable; styleText must see keys added after its + // internal cache was first populated instead of crashing on the stale cache. + it.concurrent("styleText accepts inspect.colors entries added after first call", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const util = require("node:util"); + util.styleText("red", "x", { validateStream: false }); + util.inspect.colors.myColor = [95, 39]; + const single = util.styleText("myColor", "x", { validateStream: false }); + const array = util.styleText(["bold", "myColor"], "x", { validateStream: false }); + process.stdout.write(JSON.stringify({ single, array }));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + single: "\u001b[95mx\u001b[39m", + array: "\u001b[1m\u001b[95mx\u001b[39m\u001b[22m", + }); + expect(exitCode).toBe(0); + }); + describe("getCallSites", () => { it("restores Error state when stackTraceLimit is non-writable", () => { const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); @@ -448,6 +474,26 @@ describe("util", () => { } }); + it.concurrent("is unaffected by user replacing globalThis.Error", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const util = require("node:util"); + globalThis.Error = undefined; + function outer() { return util.getCallSites(5); } + const sites = outer(); + process.stdout.write(JSON.stringify(sites.map(s => s.functionName)));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toContain("outer"); + expect(exitCode).toBe(0); + }); + it("each frame has the node v26 shape", () => { const sites = util.getCallSites(3); expect(sites.length).toBeGreaterThan(0); From 38c513d34f468c8340d5aa57453d4bccb034ffd6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:34:16 +0000 Subject: [PATCH 2/3] util: snapshot Error.captureStackTrace at module load var Error = globalThis.Error captures a reference, not a copy; a user that deletes or overwrites Error.captureStackTrace still broke getCallSites. Snapshot the function itself, matching internal/util/inspect.js and internal/assert/assertion_error.ts. --- src/js/node/util.ts | 3 ++- test/js/node/util/util.test.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/js/node/util.ts b/src/js/node/util.ts index 54ac6e735f42..47c809090c4b 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -21,6 +21,7 @@ const NumberIsSafeInteger = Number.isSafeInteger; const ObjectKeys = Object.keys; const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; var Error = globalThis.Error; +const ErrorCaptureStackTrace = Error.captureStackTrace; const { uncurryThis, SafeMap } = require("internal/primordials"); const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec); @@ -479,7 +480,7 @@ function getCallSites(frameCount = 10, options) { try { Error.stackTraceLimit = frameCount; } catch {} - Error.captureStackTrace(target, getCallSites); + ErrorCaptureStackTrace(target, getCallSites); return target.stack; } finally { Error.prepareStackTrace = savedPrepareStackTrace; diff --git a/test/js/node/util/util.test.js b/test/js/node/util/util.test.js index d26fd3e09f1b..1fe5ad17503b 100644 --- a/test/js/node/util/util.test.js +++ b/test/js/node/util/util.test.js @@ -474,12 +474,13 @@ describe("util", () => { } }); - it.concurrent("is unaffected by user replacing globalThis.Error", async () => { + it.concurrent("is unaffected by user tampering with the Error global", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", `const util = require("node:util"); + delete Error.captureStackTrace; globalThis.Error = undefined; function outer() { return util.getCallSites(5); } const sites = outer(); From 8222b861764acc5ae46e825e9113d444fc585ed3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:05:48 +0000 Subject: [PATCH 3/3] ci: retrigger