Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2329,6 +2329,27 @@ impl<AtRule> StyleSheet<AtRule> {
}
}

/// Longest input `parse_with` / `StyleAttribute::parse` accept.
///
/// Byte offsets into the input are stored as `i32` (`bun_ast::Loc` in import
/// records, CSS-module symbols, `composes` and `PropertyUsage`), and the line
/// and column numbers derived from them are stored as `i32` too
/// (`bun_ast::Location` in errors and warnings). The largest such value is
/// `len + 1`: the column of, or the 1-based line count at, the end of input.
/// Columns count UTF-16 units, never more than one per byte. Bounding the
/// length once here is what makes every one of those conversions infallible.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) const MAX_INPUT_LEN: usize = i32::MAX as usize - 1;

fn check_input_len(code: &[u8]) -> Maybe<(), Err<ParserError>> {
if code.len() > MAX_INPUT_LEN {
return Err(Err {
kind: ParserError::input_too_large,
loc: None,
});
}
Ok(())
}
Comment thread
robobun marked this conversation as resolved.
Resolved

// ── StyleSheet behavior (parse/minify/to_css) ────────────────────────────────
mod stylesheet_impl {
use super::*;
Expand Down Expand Up @@ -2563,6 +2584,7 @@ mod stylesheet_impl {
// returned `StyleSheet`.
// TODO(refactor): re-thread the lifetime through `CssRuleList<'bump, R>`
// and drop the `'static` bound on `arena`.
check_input_len(code)?;
let mut composes = ComposesMap::default();
let mut parser_extra = ParserExtra {
local_scope: LocalScope::default(),
Expand Down Expand Up @@ -2663,6 +2685,7 @@ mod stylesheet_impl {
// TODO: 'bump lifetime threading — `DeclarationBlock<'static>` in
// `StyleAttribute` vs `Parser<'a>` here; `arena: &'static Bump`
// matches the crate-wide erasure (see `parse_with`).
check_input_len(code)?;
let mut parser_extra = ParserExtra {
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
local_scope: LocalScope::default(),
symbols: SymbolList::default(),
Expand Down
6 changes: 5 additions & 1 deletion src/css/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ impl<T: fmt::Display> Err<T> {
data: bun_ast::Data {
location: match &self.loc {
Some(loc) => Some(loc.to_location(source)?),
None => None,
// Errors about the stylesheet as a whole still name the file.
None => bun_ast::Location::init_or_null(Some(source), bun_ast::Range::NONE),
},
text: text.into(),
},
Expand Down Expand Up @@ -296,6 +297,8 @@ pub enum ParserError {
expected: Str,
received: Str,
},
/// The input is longer than [`crate::css_parser::MAX_INPUT_LEN`].
input_too_large,
}

impl fmt::Display for ParserError {
Expand Down Expand Up @@ -326,6 +329,7 @@ impl fmt::Display for ParserError {
Self::unexpected_value { expected, received } => {
write!(f, "Expected {}, received {}", bs(*expected), bs(*received))
}
Self::input_too_large => f.write_str("CSS file is too large to parse (2 GiB maximum)"),
}
}
}
Expand Down
91 changes: 91 additions & 0 deletions test/js/bun/css/input-too-large.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { closeSync, openSync, statSync, writeSync } from "node:fs";
import os from "node:os";
import { join } from "node:path";

// Every byte offset the CSS parser hands to the rest of bun (import records,
// CSS module symbols, `composes`) and every line/column in its diagnostics is
// an i32, so once the tokenizer got past byte 2**31 of a stylesheet the process
// aborted with `panic: int cast: TryFromIntError(PosOverflow)`. The parser now
// refuses such input up front with an ordinary build error, before reading it.
//
// Each stylesheet here is one comment covering its first 2 GiB followed by a
// `composes` declaration, a cast site that both the bundler and
// `bun build --no-bundle` reach (a url() only becomes an import record when
// bundling). The comment body is all zero bytes, so it costs nothing to
// produce: untouched pages of a Uint8Array in one case, a hole in a sparse
// file in the other. Handing it to bun still costs the child 2 GiB of memory,
// hence the memory gate (the same one fs-oom.test.ts uses for its 2 GiB reads)
// and the generous timeouts. The tests are deliberately not concurrent, so at
// most one 2 GiB child exists at a time.
const TAIL = "*/.a{composes:b}\n";
const MESSAGE = "CSS file is too large to parse (2 GiB maximum)";

describe.skipIf(os.totalmem() < 10 * 1024 ** 3)("stylesheet of 2 GiB or more", () => {
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
test("Bun.build reports an error naming the file", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const tail = new TextEncoder().encode(${JSON.stringify(TAIL)});
const bytes = new Uint8Array(2 ** 31 + tail.length);
bytes.set([0x2f, 0x2a]); // "/*"
bytes.set(tail, 2 ** 31);
const result = await Bun.build({
entrypoints: ["/app/big.css"],
files: { "/app/big.css": bytes },
throw: false,
});
console.log(JSON.stringify({
success: result.success,
logs: result.logs.map(log => ({ level: log.level, message: log.message, file: log.position?.file })),
}));
`,
],
env: bunEnv,
stdout: "pipe",
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({
success: false,
logs: [{ level: "error", message: MESSAGE, file: "/app/big.css" }],
});
expect(exitCode).toBe(0);
}, 30_000);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

// Windows only makes a file sparse on request, so seeking past 2 GiB there
// would really write 2 GiB of zeros. The bound is the same code on every
// platform and the test above already runs there.
test.skipIf(isWindows)(
"bun build --no-bundle reports an error",
async () => {
using dir = tempDir("css-too-large", {});
const css = join(String(dir), "big.css");
const tail = Buffer.from(TAIL);
const fd = openSync(css, "w");
try {
writeSync(fd, Buffer.from("/*"), 0, 2, 0);
writeSync(fd, tail, 0, tail.length, 2 ** 31);
} finally {
closeSync(fd);
}
expect(statSync(css).size).toBe(2 ** 31 + tail.length);

await using proc = Bun.spawn({
cmd: [bunExe(), "build", "--no-bundle", css],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain(`error: ${MESSAGE}`);
expect(stdout).toBe("");
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
expect(exitCode).toBe(1);
},
30_000,
);
});
Loading