Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2068,6 +2068,9 @@ impl Log {
notes: Box<[Data]>,
args: fmt::Arguments<'_>,
) {
if !Kind::Warn.should_print(self.level) {
return;
}
let text = alloc_print(args);
self.add_formatted_msg(Kind::Warn, source, r, text, notes, false)
}
Expand Down
54 changes: 54 additions & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,60 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
Ok(())
}

/// A visit-pass warning about valid but suspect code is silent inside node_modules, as in
/// esbuild, and on the minifier's second visit of a statement.
fn warns_about_weird_code(&self) -> bool {
!self.is_revisit_for_substitution
&& !bun_paths::strings::path_contains_node_modules_folder(self.source.path.text)
}

/// `problem` is the start of the sentence, such as "Writing to read-only method".
#[cold]
#[inline(never)]
pub(crate) fn warn_private_name(
&mut self,
problem: &'static str,
name: &[u8],
loc: bun_ast::Loc,
) {
if !self.warns_about_weird_code() {
return;
}

let r = bun_ast::Range {
loc,
len: i32::try_from(name.len()).expect("int cast"),
};
self.log().add_range_warning_fmt(
Some(self.source),
r,
format_args!("{} \"{}\" will throw", problem, bstr::BStr::new(name)),
);
}

/// esbuild's `direct-eval` warning. The caller checks that the file is a bundled ES module.
#[cold]
#[inline(never)]
pub(crate) fn warn_direct_eval(&mut self, loc: bun_ast::Loc) {
if !self.warns_about_weird_code() {
return;
}

let r = js_lexer::range_of_identifier(self.source, loc);
Comment on lines +1035 to +1043

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Users who bundle a minified ES module that calls eval outside node_modules now get a stderr line of the whole minified source plus one space per column, per eval call. The new warning in warn_direct_eval at src/js_parser/p.rs:1043 carries a range on the source line, and the printer at src/ast/lib.rs:1052-1065 echoes the full line text and then pads to the column. Fix: make the warning safe on long lines before it ships, either by landing the printer cap the PR description names (#41658) first or by truncating lineText and the padding in write_format around a window of the column, so every range warning on a minified line stays bounded. The description calls this an ordering dependency, but nothing in this diff enforces it. [also at: src/js_parser/p.rs:1044 - Users who bundle a minified ES module that lives outside node_modules (a vendored .min.js, or the output of a first bun build --minify fed into a second build or --compile) now get a ~120-byte window of the line echoed to stderr once per direct eval, plus one space per column, where the base branch printed nothing.]

Why this was flagged

A project bundles a prebuilt, minified ESM file that is not under node_modules (for example a vendored lib.min.mjs, or a bundle output from bun build --minify re-bundled). The file has export or import and a direct eval( call at column N on a one-line source. visit_expr.rs:1924 now calls warn_direct_eval for every such call, and p.rs:1043 attaches a range on that line. Log printing at src/ast/lib.rs:1052-1065 writes the full line text and then write_n_bytes(to, b' ', line_offset_for_second_line), so stderr gets roughly N bytes of source plus N spaces per eval call, with no cap in this diff. On the base branch this file produces no warning so none of this is printed. The author's note says #41658 must merge first, but this checkout has no such cap, so merging in the other order ships the cost.

Verification: normal — acknowledged in diff: the bound is accurate for the padding, and nothing in this PR's own commits caps it. Trigger: bun build (CLI, logLevel >= warn) of a bundled ES module not under node_modules whose source is one long line with a direct eval( call. write_n_bytes(to, b' ', line_offset_for_second_line) (lib.rs:1065) then writes that many spaces.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is correct. It is the second bullet of Downsides.

The cost comes from the log printer and not from this warning. Location::init_or_null_impl cuts the excerpt to about 120 bytes but keeps the column of the whole line, and Data::write_format pads the caret to that column. It also keeps the whole line when the position is in its last 80 bytes. Each located diagnostic on a long line pays this on main. #41658 and #43313 are the fixes. Both need a rebase, and I asked for that.

I did not add a bound in this diff:

To make the order a property of the PR and not only a sentence in its body, this PR is now a draft. It leaves draft when #41658 is merged. I leave this thread open until then.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correction to my reply above: this PR waits for #43313 too, not only for #41658. #41658 bounds the caret padding. #43313 bounds the excerpt when the position is in the last 80 bytes of the line.

Measured on main with a warning that exists today (a read of a setter-only private name) on a 200 KB line: 200,268 B of stderr in the middle of the line, 400,196 B at its end.

Both PRs are rebased and mergeable now. This PR stays a draft until both are merged.

self.log().add_range_warning_fmt_with_notes(
Some(self.source),
r,
Box::new([bun_ast::range_data(
None,
bun_ast::Range::NONE,
"Use globalThis.eval(code) when the code reads only global variables. To give the code a variable, an import or require from this file, pass it as an argument: new Function(\"name\", code)(name)",
)]),
format_args!(
"Using direct eval with a bundler is not recommended and may cause problems"
),
);
}

// ───────────────────────────────────────────────────────────────────────
// Inlined ExpressionTransposer recursion.
// Defined as `&mut self` methods so the only live `&mut` is the caller's
Expand Down
61 changes: 18 additions & 43 deletions src/js_parser/visit/visit_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -941,49 +941,20 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
&& (kind == js_ast::symbol::Kind::PrivateMethod
|| kind == js_ast::symbol::Kind::PrivateStaticMethod)
{
let r = bun_ast::Range {
loc: e_.index.loc,
len: i32::try_from(name.len()).expect("int cast"),
};
p.log().add_range_warning_fmt(
Some(p.source),
r,
format_args!(
"Writing to read-only method \"{}\" will throw",
BStr::new(name)
),
);
p.warn_private_name("Writing to read-only method", name, e_.index.loc);
} else if in_.assign_target != js_ast::AssignTarget::None
&& (kind == js_ast::symbol::Kind::PrivateGet
|| kind == js_ast::symbol::Kind::PrivateStaticGet)
{
let r = bun_ast::Range {
loc: e_.index.loc,
len: i32::try_from(name.len()).expect("int cast"),
};
p.log().add_range_warning_fmt(
Some(p.source),
r,
format_args!(
"Writing to getter-only property \"{}\" will throw",
BStr::new(name)
),
);
p.warn_private_name("Writing to getter-only property", name, e_.index.loc);
} else if in_.assign_target != js_ast::AssignTarget::Replace
&& (kind == js_ast::symbol::Kind::PrivateSet
|| kind == js_ast::symbol::Kind::PrivateStaticSet)
{
let r = bun_ast::Range {
loc: e_.index.loc,
len: i32::try_from(name.len()).expect("int cast"),
};
p.log().add_range_warning_fmt(
Some(p.source),
r,
format_args!(
"Reading from setter-only property \"{}\" will throw",
BStr::new(name)
),
p.warn_private_name(
"Reading from setter-only property",
name,
e_.index.loc,
);
}
}
Expand Down Expand Up @@ -1941,12 +1912,18 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
{
e_.is_direct_eval = true;
Comment thread
robobun marked this conversation as resolved.
Resolved

// Pessimistically assume that if this looks like a CommonJS module
// (e.g. no "export" keywords), a direct call to "eval" means that
// code could potentially access "module" or "exports".
if p.options.bundle && !p.is_file_considered_to_have_esm_exports {
p.record_usage(p.module_ref);
p.record_usage(p.exports_ref);
if p.options.bundle {
// Pessimistically assume that if this looks like a CommonJS module
// (e.g. no "export" keywords), a direct call to "eval" means that
// code could potentially access "module" or "exports".
if !p.is_file_considered_to_have_esm_exports {
p.record_usage(p.module_ref);
p.record_usage(p.exports_ref);
}

if p.is_file_considered_to_have_esm_exports || p.has_es_module_syntax {
p.warn_direct_eval(e_.target.loc);
}
}

// Walk `Scope.parent` (`Option<StoreRef<Scope>>`) via the safe
Expand All @@ -1957,8 +1934,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
scope.contains_direct_eval = true;
scope_iter = scope.parent;
}

// TODO: Log a build note for this like esbuild does
}
}
Data::EDot(dot) => {
Expand Down
99 changes: 99 additions & 0 deletions test/bundler/bun-build-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,105 @@ describe("Bun.build", () => {
Bun.gc(true);
});

test("warns about a direct eval in an ES module", async () => {
const evalLine = `console.log(eval("secret + helper()"));`;
using dir = tempDir("bun-build-api-direct-eval", {
"esm.js": `import { helper } from "./lib.js";\nconst secret = 42;\n${evalLine}\n`,
"lib.js": `export function helper() { return 1; }\n`,
"commonjs.js": `module.exports = eval("1");\n`,
});
// `notes` is not in the `BuildMessage` type.
const plain = (message: any) => ({ level: message.level, message: message.message, position: message.position });
const logsOf = async (entry: string) => {
const build = await Bun.build({ entrypoints: [join(String(dir), entry)] });
return {
success: build.success,
logs: build.logs.map((log: any) => ({ ...plain(log), notes: log.notes.map(plain) })),
};
};

expect(await logsOf("esm.js")).toEqual({
success: true,
logs: [
{
level: "warn",
message: "Using direct eval with a bundler is not recommended and may cause problems",
position: {
file: expect.stringMatching(/esm\.js$/),
namespace: "file",
line: 3,
column: evalLine.indexOf("eval") + 1,
length: "eval".length,
offset: expect.any(Number),
lineText: evalLine,
},
notes: [
{
level: "note",
message:
'Use globalThis.eval(code) when the code reads only global variables. To give the code a variable, an import or require from this file, pass it as an argument: new Function("name", code)(name)',
position: null,
},
],
},
],
});
expect(await logsOf("commonjs.js")).toEqual({ success: true, logs: [] });
});

test('logLevel = "error" in bunfig.toml drops the direct eval warning', async () => {
const project = {
"entry.js": `export const value = eval("1");\n`,
"build.js": `
const build = await Bun.build({ entrypoints: ["./entry.js"] });
console.log(JSON.stringify({ success: build.success, logs: build.logs.map(log => log.message) }));
`,
};
using dir = tempDir("bun-build-api-direct-eval-log-level", {
default: project,
quiet: { ...project, "bunfig.toml": `logLevel = "error"\n` },
});
const build = async (project: string) => {
await using proc = Bun.spawn({
cmd: [bunExe(), "build.js"],
env: bunEnv,
cwd: join(String(dir), project),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout: JSON.parse(stdout), stderr, exitCode };
};

expect({ default: await build("default"), quiet: await build("quiet") }).toEqual({
default: {
stdout: {
success: true,
logs: ["Using direct eval with a bundler is not recommended and may cause problems"],
},
stderr: "",
exitCode: 0,
},
quiet: { stdout: { success: true, logs: [] }, stderr: "", exitCode: 0 },
});
});

test("a build of an ES module bundle warns about a direct eval that came from node_modules", async () => {
using dir = tempDir("bun-build-api-direct-eval-second-build", {
"entry.js": `import { run } from "dep";\nexport const value = run("1");\n`,
"node_modules/dep/package.json": `{ "name": "dep", "type": "module", "main": "index.js" }`,
"node_modules/dep/index.js": `export function run(code) { return eval(code); }\n`,
});
const messages = (build: Awaited<ReturnType<typeof Bun.build>>) => build.logs.map(log => log.message);

const first = await Bun.build({ entrypoints: [join(String(dir), "entry.js")], outdir: join(String(dir), "dist") });
const second = await Bun.build({ entrypoints: [join(String(dir), "dist", "entry.js")] });
expect({ first: messages(first), second: messages(second) }).toEqual({
first: [],
second: ["Using direct eval with a bundler is not recommended and may cause problems"],
});
});

test("Bun.write(BuildArtifact)", async () => {
Bun.gc(true);
const tmpdir = tempDirWithFiles("bun-build-api-write", {
Expand Down
3 changes: 3 additions & 0 deletions test/bundler/compile-argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ describe("bundler", () => {
`,
"/imported.ts": /* js */ `export default 2;`,
},
bundleWarnings: {
"/see.ts": ["Using direct eval with a bundler is not recommended and may cause problems"],
},
entryPointsRaw: ["./entry.ts", "./worker.ts"],
outfile: "dist/out",
run: {
Expand Down
Loading
Loading