From 435d6047e5a3fe3a4a4a93819d5044feb5ab0ce8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:30:36 +0000 Subject: [PATCH 1/7] logger: keep the whole source line for a redacted diagnostic A long line in bunfig.toml or .npmrc is cut to about 120 bytes around the error when the diagnostic is created. The printer redacts a secret by the key in front of it, so a cut that drops the key prints the value. Keep the whole line when the message has redact_sensitive_information set. Other diagnostics keep the window. --- src/ast/lib.rs | 53 ++++++++++++++++--- test/cli/install/redacted-config-logs.test.ts | 32 +++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 8f450860cfc8..2e1e409000eb 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -697,6 +697,27 @@ impl Default for Location { } } +/// How much of the source line `Location::line_text` keeps. +#[derive(Clone, Copy, PartialEq, Eq)] +enum LineText { + /// About 120 bytes around the error. + Windowed, + /// The whole line. The printer redacts a secret by the key in front of + /// it (`token = "..."`), so a message with + /// `redact_sensitive_information` must not lose the key to the window. + Whole, +} + +impl LineText { + fn for_msg(redact_sensitive_information: bool) -> LineText { + if redact_sensitive_information { + LineText::Whole + } else { + LineText::Windowed + } + } +} + impl Location { pub(crate) fn memory_cost(&self) -> usize { let mut cost: usize = 0; @@ -763,7 +784,7 @@ impl Location { } pub fn init_or_null(_source: Option<&Source>, r: Range) -> Option { - Self::init_or_null_impl(_source, r, None) + Self::init_or_null_impl(_source, r, None, LineText::Windowed) } /// `init_or_null`, but computing the line/column through a @@ -773,14 +794,16 @@ impl Location { _source: Option<&Source>, r: Range, tracker: &mut LineColumnTracker, + line_text: LineText, ) -> Option { - Self::init_or_null_impl(_source, r, Some(tracker)) + Self::init_or_null_impl(_source, r, Some(tracker), line_text) } fn init_or_null_impl( _source: Option<&Source>, r: Range, tracker: Option<&mut LineColumnTracker>, + line_text: LineText, ) -> Option { if let Some(source) = _source { if r.is_empty() { @@ -805,7 +828,7 @@ impl Location { let offset_in_line = clamp_error_offset(&source.contents, r.loc) .saturating_sub(data.line_start) .min(full_line.len()); - if full_line.len() > 80 + offset_in_line { + if line_text == LineText::Windowed && full_line.len() > 80 + offset_in_line { let mut lo = offset_in_line.saturating_sub(40); let mut hi = (offset_in_line + 80).min(full_line.len()); while lo > 0 && !bun_core::strings::is_utf8_char_boundary(full_line[lo]) { @@ -833,7 +856,8 @@ impl Location { // drops on the parse-error path *before* `process_fetch_log` // clones the `Msg` into a `BuildMessage`, so own the bytes here // instead of borrowing `source.contents`. `full_line` is - // bounded (≤ ~120 bytes) and only materialized on diagnostic + // bounded (≤ ~120 bytes, or one line of a config file for + // `LineText::Whole`) and only materialized on diagnostic // paths. line_text: Some(Cow::Owned(bun_core::trim_left(full_line, b"\n\r").to_vec())), offset: usize::try_from(r.loc.start.max(0)).expect("int cast"), @@ -1472,12 +1496,23 @@ impl Log { source: Option<&Source>, r: Range, text: impl IntoText, + ) -> Data { + self.tracked_range_data_with(source, r, text, LineText::Windowed) + } + + fn tracked_range_data_with( + &mut self, + source: Option<&Source>, + r: Range, + text: impl IntoText, + line_text: LineText, ) -> Data { let location = if source.is_some() { Location::init_or_null_tracked( source, r, self.line_column_tracker.get_or_insert_default(), + line_text, ) } else { Location::init_or_null(source, r) @@ -1631,7 +1666,12 @@ impl Log { _ => {} } let data = self - .tracked_range_data(source, r, text) + .tracked_range_data_with( + source, + r, + text, + LineText::for_msg(redact_sensitive_information), + ) .clone_line_text(self.clone_line_text); self.add_msg(Msg { kind, @@ -2120,13 +2160,14 @@ impl Log { #[cold] pub fn add_error_opts(&mut self, text: Str, opts: AddErrorOptions<'_>) { self.errors += 1; - let data = self.tracked_range_data( + let data = self.tracked_range_data_with( opts.source, Range { loc: opts.loc, len: opts.len, }, text, + LineText::for_msg(opts.redact_sensitive_information), ); self.add_msg(Msg { kind: Kind::Err, diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 6c49d43123b4..fccec0e9b4bc 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -141,6 +141,38 @@ test("bunfig password value is masked in config error output", async () => { expect(coloredExit).toBe(1); }); +test.concurrent("bunfig token value is masked when the error is on a long line", async () => { + // The key is more than 40 bytes before the error and more than 80 bytes + // of comment follow it, so the excerpt window would cut `token = ` away. + const secret = Buffer.alloc(72, "SECRET").toString(); + const padding = Buffer.alloc(120, "x").toString(); + using dir = tempDir("redacted-bunfig-long-line", { + "bunfig.toml": `[install]\ntoken = "${secret}" ] # ${padding}\n`, + "package.json": "{}", + }); + + for (const env of [ + { ...bunEnv, NO_COLOR: "1" }, + { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + ]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env, + stdout: "pipe", + stderr: "pipe", + }); + + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(out).not.toContain("SECRET"); + expect(err).not.toContain("SECRET"); + expect(err).toContain(`"${"*".repeat(secret.length)}"`); + expect(err).toContain("Expected a newline or end of file after a key/value pair"); + expect(exitCode).toBe(1); + } +}); + describe.concurrent("redact", async () => { const tests = [ { From 3f7883f741eb5bea10b61c0eb6760d6197d95776 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:45:04 +0000 Subject: [PATCH 2/7] test: build the expected mask with Buffer.alloc --- test/cli/install/redacted-config-logs.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index fccec0e9b4bc..59b84032ae12 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -145,6 +145,7 @@ test.concurrent("bunfig token value is masked when the error is on a long line", // The key is more than 40 bytes before the error and more than 80 bytes // of comment follow it, so the excerpt window would cut `token = ` away. const secret = Buffer.alloc(72, "SECRET").toString(); + const masked = Buffer.alloc(secret.length, "*").toString(); const padding = Buffer.alloc(120, "x").toString(); using dir = tempDir("redacted-bunfig-long-line", { "bunfig.toml": `[install]\ntoken = "${secret}" ] # ${padding}\n`, @@ -167,7 +168,7 @@ test.concurrent("bunfig token value is masked when the error is on a long line", expect(out).not.toContain("SECRET"); expect(err).not.toContain("SECRET"); - expect(err).toContain(`"${"*".repeat(secret.length)}"`); + expect(err).toContain(`"${masked}"`); expect(err).toContain("Expected a newline or end of file after a key/value pair"); expect(exitCode).toBe(1); } From 05875ff81a0d9990a68f2e6df99fcdbffbcae16c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:46:03 +0000 Subject: [PATCH 3/7] logger: shorten the LineText comments --- src/ast/lib.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 2e1e409000eb..beaa68ac5263 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -702,9 +702,7 @@ impl Default for Location { enum LineText { /// About 120 bytes around the error. Windowed, - /// The whole line. The printer redacts a secret by the key in front of - /// it (`token = "..."`), so a message with - /// `redact_sensitive_information` must not lose the key to the window. + /// The whole line, so the redaction still sees the key before a secret. Whole, } @@ -855,10 +853,7 @@ impl Location { // `source_backing` in `Transpiler::parse_*` is RAII and // drops on the parse-error path *before* `process_fetch_log` // clones the `Msg` into a `BuildMessage`, so own the bytes here - // instead of borrowing `source.contents`. `full_line` is - // bounded (≤ ~120 bytes, or one line of a config file for - // `LineText::Whole`) and only materialized on diagnostic - // paths. + // instead of borrowing `source.contents`. line_text: Some(Cow::Owned(bun_core::trim_left(full_line, b"\n\r").to_vec())), offset: usize::try_from(r.loc.start.max(0)).expect("int cast"), }); From 981ec74d5903892c8198d4c22ffac220987f83a8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:56:59 +0000 Subject: [PATCH 4/7] test: cover the bunfig validation error path on a long line --- test/cli/install/redacted-config-logs.test.ts | 66 ++++++++++++------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index 59b84032ae12..c0531915e612 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -141,36 +141,52 @@ test("bunfig password value is masked in config error output", async () => { expect(coloredExit).toBe(1); }); -test.concurrent("bunfig token value is masked when the error is on a long line", async () => { +describe.concurrent("bunfig token value is masked when the error is on a long line", () => { // The key is more than 40 bytes before the error and more than 80 bytes - // of comment follow it, so the excerpt window would cut `token = ` away. + // follow it, so the excerpt window would cut `token = ` away. const secret = Buffer.alloc(72, "SECRET").toString(); const masked = Buffer.alloc(secret.length, "*").toString(); - const padding = Buffer.alloc(120, "x").toString(); - using dir = tempDir("redacted-bunfig-long-line", { - "bunfig.toml": `[install]\ntoken = "${secret}" ] # ${padding}\n`, - "package.json": "{}", - }); - - for (const env of [ - { ...bunEnv, NO_COLOR: "1" }, - { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, - ]) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: String(dir), - env, - stdout: "pipe", - stderr: "pipe", - }); + const cases = [ + { + title: "toml syntax error", + bunfig: `[install]\ntoken = "${secret}" ] # ${Buffer.alloc(120, "x").toString()}\n`, + error: "Expected a newline or end of file after a key/value pair", + }, + { + title: "bunfig validation error", + bunfig: `install = { registry = { token = "${secret}" }, cafile = 1, ca = "${Buffer.alloc(90, "x").toString()}" }\n`, + error: "Invalid cafile. Expected a string.", + }, + ]; - const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + for (const { title, bunfig, error } of cases) { + test(title, async () => { + using dir = tempDir("redacted-bunfig-long-line", { + "bunfig.toml": bunfig, + "package.json": "{}", + }); - expect(out).not.toContain("SECRET"); - expect(err).not.toContain("SECRET"); - expect(err).toContain(`"${masked}"`); - expect(err).toContain("Expected a newline or end of file after a key/value pair"); - expect(exitCode).toBe(1); + for (const env of [ + { ...bunEnv, NO_COLOR: "1" }, + { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + ]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env, + stdout: "pipe", + stderr: "pipe", + }); + + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(out).not.toContain("SECRET"); + expect(err).not.toContain("SECRET"); + expect(err).toContain(`"${masked}"`); + expect(err).toContain(error); + expect(exitCode).toBe(1); + } + }); } }); From 738fce70f22de93838e77846a8074c2bb504c0a5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:31:01 +0000 Subject: [PATCH 5/7] logger: mask secrets in the source line before the excerpt window Keep the window for every diagnostic. For a message with redact_sensitive_information set, run redacted_source over the whole line first, so the key in front of a secret is seen before the window can cut it away. The masked line has the same byte length, so the window bounds and the caret do not move. --- src/ast/lib.rs | 63 ++++++++----------- src/bun_core/fmt.rs | 2 +- test/cli/install/redacted-config-logs.test.ts | 9 +-- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index beaa68ac5263..423e120024fe 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -697,25 +697,6 @@ impl Default for Location { } } -/// How much of the source line `Location::line_text` keeps. -#[derive(Clone, Copy, PartialEq, Eq)] -enum LineText { - /// About 120 bytes around the error. - Windowed, - /// The whole line, so the redaction still sees the key before a secret. - Whole, -} - -impl LineText { - fn for_msg(redact_sensitive_information: bool) -> LineText { - if redact_sensitive_information { - LineText::Whole - } else { - LineText::Windowed - } - } -} - impl Location { pub(crate) fn memory_cost(&self) -> usize { let mut cost: usize = 0; @@ -782,7 +763,7 @@ impl Location { } pub fn init_or_null(_source: Option<&Source>, r: Range) -> Option { - Self::init_or_null_impl(_source, r, None, LineText::Windowed) + Self::init_or_null_impl(_source, r, None, false) } /// `init_or_null`, but computing the line/column through a @@ -792,16 +773,16 @@ impl Location { _source: Option<&Source>, r: Range, tracker: &mut LineColumnTracker, - line_text: LineText, + redact_sensitive_information: bool, ) -> Option { - Self::init_or_null_impl(_source, r, Some(tracker), line_text) + Self::init_or_null_impl(_source, r, Some(tracker), redact_sensitive_information) } fn init_or_null_impl( _source: Option<&Source>, r: Range, tracker: Option<&mut LineColumnTracker>, - line_text: LineText, + redact_sensitive_information: bool, ) -> Option { if let Some(source) = _source { if r.is_empty() { @@ -819,14 +800,27 @@ impl Location { Some(tracker) => tracker.error_position(source, r.loc), None => source.init_error_position(r.loc), }; - let mut full_line = &source.contents[data.line_start..data.line_end]; + // Mask secrets before the window below. The masking finds a + // secret by the key in front of it, and the window can cut the + // key away. `redacted_source` keeps the byte length. + let masked: Cow<'_, [u8]> = if redact_sensitive_information { + alloc_print(format_args!( + "{}", + bun_core::fmt::redacted_source( + &source.contents[data.line_start..data.line_end] + ) + )) + } else { + Cow::Borrowed(&source.contents[data.line_start..data.line_end]) + }; + let mut full_line: &[u8] = &masked; // Window a long line to ~120 bytes around the error. Bounds are // BYTE offsets; the gate keeps the original shape (no left trim for // an error in the last 80 bytes) so `write_format`'s caret aligns. let offset_in_line = clamp_error_offset(&source.contents, r.loc) .saturating_sub(data.line_start) .min(full_line.len()); - if line_text == LineText::Windowed && full_line.len() > 80 + offset_in_line { + if full_line.len() > 80 + offset_in_line { let mut lo = offset_in_line.saturating_sub(40); let mut hi = (offset_in_line + 80).min(full_line.len()); while lo > 0 && !bun_core::strings::is_utf8_char_boundary(full_line[lo]) { @@ -853,7 +847,9 @@ impl Location { // `source_backing` in `Transpiler::parse_*` is RAII and // drops on the parse-error path *before* `process_fetch_log` // clones the `Msg` into a `BuildMessage`, so own the bytes here - // instead of borrowing `source.contents`. + // instead of borrowing `source.contents`. `full_line` is + // bounded (≤ ~120 bytes) and only materialized on diagnostic + // paths. line_text: Some(Cow::Owned(bun_core::trim_left(full_line, b"\n\r").to_vec())), offset: usize::try_from(r.loc.start.max(0)).expect("int cast"), }); @@ -1492,7 +1488,7 @@ impl Log { r: Range, text: impl IntoText, ) -> Data { - self.tracked_range_data_with(source, r, text, LineText::Windowed) + self.tracked_range_data_with(source, r, text, false) } fn tracked_range_data_with( @@ -1500,14 +1496,14 @@ impl Log { source: Option<&Source>, r: Range, text: impl IntoText, - line_text: LineText, + redact_sensitive_information: bool, ) -> Data { let location = if source.is_some() { Location::init_or_null_tracked( source, r, self.line_column_tracker.get_or_insert_default(), - line_text, + redact_sensitive_information, ) } else { Location::init_or_null(source, r) @@ -1661,12 +1657,7 @@ impl Log { _ => {} } let data = self - .tracked_range_data_with( - source, - r, - text, - LineText::for_msg(redact_sensitive_information), - ) + .tracked_range_data_with(source, r, text, redact_sensitive_information) .clone_line_text(self.clone_line_text); self.add_msg(Msg { kind, @@ -2162,7 +2153,7 @@ impl Log { len: opts.len, }, text, - LineText::for_msg(opts.redact_sensitive_information), + opts.redact_sensitive_information, ); self.add_msg(Msg { kind: Kind::Err, diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index bdf26a693d2c..332130fee0bf 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -332,7 +332,7 @@ impl Display for RedactedSourceFormatter<'_> { } } -pub(crate) fn redacted_source(str: &[u8]) -> RedactedSourceFormatter<'_> { +pub fn redacted_source(str: &[u8]) -> RedactedSourceFormatter<'_> { RedactedSourceFormatter { text: str } } diff --git a/test/cli/install/redacted-config-logs.test.ts b/test/cli/install/redacted-config-logs.test.ts index c0531915e612..c77f5c09f987 100644 --- a/test/cli/install/redacted-config-logs.test.ts +++ b/test/cli/install/redacted-config-logs.test.ts @@ -143,23 +143,24 @@ test("bunfig password value is masked in config error output", async () => { describe.concurrent("bunfig token value is masked when the error is on a long line", () => { // The key is more than 40 bytes before the error and more than 80 bytes - // follow it, so the excerpt window would cut `token = ` away. + // follow it, so the printed excerpt starts inside the secret. const secret = Buffer.alloc(72, "SECRET").toString(); - const masked = Buffer.alloc(secret.length, "*").toString(); const cases = [ { title: "toml syntax error", bunfig: `[install]\ntoken = "${secret}" ] # ${Buffer.alloc(120, "x").toString()}\n`, + excerpt: /^2 \| \*+" \] # x+$/m, error: "Expected a newline or end of file after a key/value pair", }, { title: "bunfig validation error", bunfig: `install = { registry = { token = "${secret}" }, cafile = 1, ca = "${Buffer.alloc(90, "x").toString()}" }\n`, + excerpt: /^1 \| \*+" }, cafile = 1, ca = "x+$/m, error: "Invalid cafile. Expected a string.", }, ]; - for (const { title, bunfig, error } of cases) { + for (const { title, bunfig, excerpt, error } of cases) { test(title, async () => { using dir = tempDir("redacted-bunfig-long-line", { "bunfig.toml": bunfig, @@ -182,7 +183,7 @@ describe.concurrent("bunfig token value is masked when the error is on a long li expect(out).not.toContain("SECRET"); expect(err).not.toContain("SECRET"); - expect(err).toContain(`"${masked}"`); + expect(Bun.stripANSI(err)).toMatch(excerpt); expect(err).toContain(error); expect(exitCode).toBe(1); } From 614bde0b3983d3ec9ea372c4cd3615776b27b7fb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:31:46 +0000 Subject: [PATCH 6/7] logger: shorten the masking comment --- src/ast/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 423e120024fe..037fcf53d8f8 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -800,9 +800,7 @@ impl Location { Some(tracker) => tracker.error_position(source, r.loc), None => source.init_error_position(r.loc), }; - // Mask secrets before the window below. The masking finds a - // secret by the key in front of it, and the window can cut the - // key away. `redacted_source` keeps the byte length. + // Mask before the window: it can cut away the key that marks a secret. let masked: Cow<'_, [u8]> = if redact_sensitive_information { alloc_print(format_args!( "{}", From 1acbdcb023a6983b67f29be64fe892cef5fc765b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:26:32 +0000 Subject: [PATCH 7/7] ci: retrigger