diff --git a/Cargo.lock b/Cargo.lock index 5cebe1131078..b1b36a6808de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -263,6 +263,7 @@ dependencies = [ name = "bun_bin" version = "0.0.0" dependencies = [ + "bstr", "bun_alloc", "bun_core", "bun_crash_handler", diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 7e1e53ed2d75..c7088f5cc8c2 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1866,7 +1866,15 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { text = &text[1..]; } - if !text.is_empty() && (text[0] == b'=' || text[0] == b':') { + // A redacted keyword followed by nothing but whitespace: + // the loop above consumed the rest of the input, so there + // is no value left to redact (`text[0]` below would be out + // of bounds). + if text.is_empty() { + return Ok(()); + } + + if text[0] == b'=' || text[0] == b':' { writer.write_char(text[0] as char)?; text = &text[1..]; while !text.is_empty() && text[0].is_ascii_whitespace() { @@ -2021,6 +2029,14 @@ impl Display for QuickAndDirtyJavaScriptSyntaxHighlighter<'_> { continue; } else if self.opts.redact_sensitive_information { 'try_redact: { + // `i == 0` happens when a `${...}` interpolation ended + // exactly at the end of the input: the scan loop above + // resets `i` to 0 and exits with `text` empty, so there + // is no quoted content to inspect (`text[1..0]` would + // be out of range). + if i == 0 { + break 'try_redact; + } let mut inner = &text[1..i]; if !inner.is_empty() && inner[inner.len() - 1] == char_ { inner = &inner[..inner.len() - 1]; diff --git a/src/bun_core/fmt.zig b/src/bun_core/fmt.zig index 496a40348045..42f1b0a9a48a 100644 --- a/src/bun_core/fmt.zig +++ b/src/bun_core/fmt.zig @@ -944,7 +944,13 @@ pub const QuickAndDirtyJavaScriptSyntaxHighlighter = struct { text = text[1..]; } - if (text.len > 0 and (text[0] == '=' or text[0] == ':')) { + // A redacted keyword followed by nothing but whitespace: + // the loop above consumed the rest of the input, so there + // is no value left to redact (`text[0]` below would be out + // of bounds). + if (text.len == 0) return; + + if (text[0] == '=' or text[0] == ':') { try writer.writeByte(text[0]); text = text[1..]; while (text.len > 0 and std.ascii.isWhitespace(text[0])) { @@ -1066,6 +1072,12 @@ pub const QuickAndDirtyJavaScriptSyntaxHighlighter = struct { continue; } else if (this.opts.redact_sensitive_information) { try_redact: { + // `i == 0` happens when a `${...}` interpolation ended + // exactly at the end of the input: the scan loop above + // resets `i` to 0 and exits with `text` empty, so there + // is no quoted content to inspect (`text[1..0]` would + // be out of range). + if (i == 0) break :try_redact; var inner = text[1..i]; if (inner.len > 0 and inner[inner.len - 1] == char) { inner = inner[0 .. inner.len - 1]; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index f09a2b396931..cfad17b1e31f 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -10,6 +10,7 @@ const fmtBinding = $bindgenFn("fmt_jsc.bind.ts", "fmtString"); export const highlightJavaScript = (code: string) => fmtBinding(code, "highlight-javascript"); +export const highlightJavaScriptRedacted = (code: string) => fmtBinding(code, "highlight-javascript-redacted"); export const escapePowershell = (code: string) => fmtBinding(code, "escape-powershell"); export const canonicalizeIP = $newCppFunction("NodeTLS.cpp", "Bun__canonicalizeIP", 1); diff --git a/src/jsc/fmt_jsc.bind.ts b/src/jsc/fmt_jsc.bind.ts index cae52da42c2d..9932349958a2 100644 --- a/src/jsc/fmt_jsc.bind.ts +++ b/src/jsc/fmt_jsc.bind.ts @@ -2,7 +2,7 @@ import { fn, t } from "bindgen"; const implNamespace = "js_bindings"; -export const Formatter = t.stringEnum("highlight-javascript", "escape-powershell"); +export const Formatter = t.stringEnum("highlight-javascript", "highlight-javascript-redacted", "escape-powershell"); export const fmtString = fn({ implNamespace, diff --git a/src/jsc/fmt_jsc.rs b/src/jsc/fmt_jsc.rs index 9820665ecc9d..41fbde21b0c0 100644 --- a/src/jsc/fmt_jsc.rs +++ b/src/jsc/fmt_jsc.rs @@ -21,6 +21,7 @@ pub mod js_bindings { pub enum Formatter { EscapePowershell = 0, HighlightJavascript = 1, + HighlightJavascriptRedacted = 2, } /// Internal function for testing in highlighter.test.ts @@ -44,6 +45,17 @@ pub mod js_bindings { ); write!(writer, "{}", formatter).map_err(|_| global.throw_out_of_memory())?; } + Formatter::HighlightJavascriptRedacted => { + let formatter = fmt::fmt_javascript( + code, + fmt::HighlighterOptions { + enable_colors: true, + check_for_unhighlighted_write: false, + redact_sensitive_information: true, + }, + ); + write!(writer, "{}", formatter).map_err(|_| global.throw_out_of_memory())?; + } Formatter::EscapePowershell => { write!(writer, "{}", fmt::escape_powershell(code)) .map_err(|_| global.throw_out_of_memory())?; diff --git a/test/js/bun/util/highlighter.test.ts b/test/js/bun/util/highlighter.test.ts index dc162aa37b3f..395b8adb2fbb 100644 --- a/test/js/bun/util/highlighter.test.ts +++ b/test/js/bun/util/highlighter.test.ts @@ -1,5 +1,8 @@ -import { highlightJavaScript as highlighter } from "bun:internal-for-testing"; +import * as internalForTesting from "bun:internal-for-testing"; import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +const { highlightJavaScript: highlighter, highlightJavaScriptRedacted: highlighterRedacted } = internalForTesting; test("highlighter", () => { expect(highlighter("`can do ${123} ${'123'} ${`123`}`").length).toBeLessThan(150); @@ -17,3 +20,56 @@ test.each([ ])("highlighter does not read past end of input for %p", input => { expect(typeof highlighter(input)).toBe("string"); }); + +// A `${...}` interpolation ending exactly at the end of the input exits the +// string scan with `i == 0` and `text` fully consumed; the redacting +// highlighter then sliced `text[1..0]` (range start index 1 out of range for +// slice of length 0). +test.each([ + "`${}", // empty interpolation, nothing after + "`${0}", // interpolation with content, nothing after + "`a${bc}", // text before the interpolation + "`${x}${y}", // two interpolations back to back +])("redacting highlighter handles `${}` at end of input for %p", input => { + expect(typeof highlighterRedacted(input)).toBe("string"); +}); + +// A redacted keyword followed by nothing but whitespace used to drain `text` +// in the whitespace-skip loop and then index `text[0]` on an empty slice +// (index out of bounds: the len is 0 but the index is 0). +test.each([ + "token ", // redacted keyword, trailing space + "email\n", // redacted keyword, trailing newline + "_auth\t", // redacted keyword, trailing tab + "_password ", // redacted keyword, several trailing spaces + "x token = ", // value also drained after the separator +])("redacting highlighter handles redacted keyword at end of input for %p", input => { + expect(typeof highlighterRedacted(input)).toBe("string"); +}); + +test("redacting highlighter still redacts values", () => { + const out = highlighterRedacted('_authToken = "npm_123456"'); + expect(out).not.toContain("npm_123456"); + expect(out).toContain("*"); +}); + +// End-to-end: an error in bunfig.toml whose source line ends with an +// unterminated template interpolation is printed through the redacting syntax +// highlighter. This used to panic while printing the error message. +test("bunfig error on a line ending in `${}` does not crash", async () => { + using dir = tempDir("bunfig-highlighter", { + "bunfig.toml": "logLevel = 3 # `${}\n", + "index.js": `console.log("hi");`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "index.js"], + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("expected string"); + expect(stdout).toContain("hi"); + expect(exitCode).toBe(0); +});