Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
5 changes: 4 additions & 1 deletion src/ast/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1105,7 +1105,10 @@ impl BabyString {
if substring.is_empty() {
return BabyString::new(0, 0);
}
let off = bun_core::strings::index_of(container, substring).expect("unreachable");
// A lossily formatted `container` may not embed `substring`; report no specifier.
let Some(off) = bun_core::strings::index_of(container, substring) else {
return BabyString::new(0, 0);
};
BabyString::new(off as u16, substring.len() as u16) // @truncate
}

Expand Down
30 changes: 29 additions & 1 deletion src/bun_core/string/immutable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use core::cmp::Ordering;

use crate::BoundedArray;
use crate::CrateError as Error;
use bun_alloc::AllocError;
use bun_alloc::{AllocError, ArenaVec, ArenaVecExt, MimallocArena};
use bun_highway as highway;
use bun_simdutf_sys::simdutf;

Expand Down Expand Up @@ -1356,6 +1356,34 @@ pub fn str_utf8(bytes: &[u8]) -> Option<&str> {
}
}

/// `(bytes, None)` if already valid UTF-8, else an `arena` copy with each ill-formed sequence replaced by U+FFFD, and the offset of the first one.
pub fn replace_invalid_utf8<'a>(
bytes: &'a [u8],
arena: &'a MimallocArena,
) -> (&'a [u8], Option<usize>) {
if is_valid_utf8(bytes) {
return (bytes, None);
}
const REPLACEMENT: &[u8] = "\u{FFFD}".as_bytes();
let first_invalid = bytes.utf8_chunks().next().map(|chunk| chunk.valid().len());
let mut out_len = 0;
for chunk in bytes.utf8_chunks() {
out_len += chunk.valid().len();
if !chunk.invalid().is_empty() {
out_len += REPLACEMENT.len();
}
}
let mut out = ArenaVec::<u8>::with_capacity_in(out_len, arena);
for chunk in bytes.utf8_chunks() {
out.extend_from_slice(chunk.valid().as_bytes());
if !chunk.invalid().is_empty() {
out.extend_from_slice(REPLACEMENT);
}
}
debug_assert_eq!(out.len(), out_len);
(out.into_bump_slice(), first_invalid)
}

pub use index_of_newline_or_non_ascii as index_of_newline_or_non_ascii_or_ansi;

/// Checks if slice[offset..] has any < 0x20 or > 127 characters
Expand Down
10 changes: 9 additions & 1 deletion src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2395,7 +2395,12 @@ pub mod parse_worker {
}
*step = Step::Parse;

let entry_contents: &[u8] = entry.contents.as_slice();
// The CSS tokenizer needs valid UTF-8; decode before `source` is built so offsets match.
let (entry_contents, first_invalid_utf8) = if loader == Loader::Css {
strings::replace_invalid_utf8(entry.contents.as_slice(), bump)
} else {
(entry.contents.as_slice(), None)
};
let is_empty = strings::is_all_whitespace(entry_contents);

// SAFETY: `transpiler` derived from a live `&mut` above. Reborrow only the
Comment thread
robobun marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -2462,6 +2467,9 @@ pub mod parse_worker {
contents_is_recycled: false,
..Default::default()
});
if let Some(first_invalid) = first_invalid_utf8 {
bun_css::warn_invalid_utf8(log, source, first_invalid);
}

let target = (if task.source_index.get() == 1 {
target_from_hashbang(entry_contents)
Expand Down
8 changes: 7 additions & 1 deletion src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3179,9 +3179,15 @@ impl<'a> Transpiler<'a> {
// `'bump`-threading note).
let alloc: &'static Arena = unsafe { bun_ptr::detach_lifetime_ref::<Arena>(self.arena) };

// The CSS tokenizer requires well-formed UTF-8 (see `ParseTask`).
let (code, first_invalid_utf8) = strings::replace_invalid_utf8(entry.contents(), alloc);
if let Some(first_invalid) = first_invalid_utf8 {
let source = bun_ast::Source::init_path_string(file_path_text, code);
bun_css::warn_invalid_utf8(self.log_mut(), &source, first_invalid);
}
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
let (mut sheet, extra) = match bun_css::StyleSheet::<bun_css::DefaultAtRule>::parse(
alloc,
entry.contents(),
code,
opts,
None,
bun_ast::Index::source(0u32),
Expand Down
42 changes: 42 additions & 0 deletions src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4098,6 +4098,10 @@ pub(crate) unsafe fn src_str(s: &[u8]) -> &'static [u8] {

impl<'a> Tokenizer<'a> {
pub(crate) fn init_with_arena(src: &'a [u8], arena: &'a Bump) -> Tokenizer<'a> {
debug_assert!(
strings::is_valid_utf8(src),
"CSS tokenizer input must be valid UTF-8 (see strings::replace_invalid_utf8)"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Tokenizer {
src,
position: 0,
Expand Down Expand Up @@ -5161,6 +5165,44 @@ pub(crate) fn split_source_map(contents: &[u8]) -> Option<&[u8]> {
None
}

/// Warns that `strings::replace_invalid_utf8` changed `source`, at the first replaced sequence.
pub fn warn_invalid_utf8(log: &mut Log, source: &bun_ast::Source, first_invalid: usize) {
let range = bun_ast::Range {
loc: bun_ast::Loc {
start: i32::try_from(first_invalid).unwrap_or(i32::MAX),
},
len: REPLACEMENT_CHAR_UTF8.len() as i32,
};
match declared_charset(&source.contents) {
Some(charset) if !strings::eql_case_insensitive_ascii(charset, b"utf-8", true) => log
.add_range_warning_fmt(
Some(source),
range,
format_args!(
"@charset \"{}\" is not supported, this file was read as UTF-8 and each invalid byte sequence was replaced with U+FFFD",
bstr::BStr::new(charset)
),
),
_ => log.add_range_warning_fmt(
Some(source),
range,
format_args!(
"This file is not valid UTF-8, each invalid byte sequence was replaced with U+FFFD"
),
),
}
}

/// The label of a leading `@charset "…";`, matched as the byte pattern of css-syntax-3 §3.2.
fn declared_charset(contents: &[u8]) -> Option<&[u8]> {
let rest = contents.strip_prefix(b"@charset \"")?;
let end = strings::index_of_char_usize(&rest[..rest.len().min(1024)], b'"')?;
let label = &rest[..end];
(rest.get(end + 1) == Some(&b';')
&& label.iter().all(|b| matches!(b, 0x16..=0x21 | 0x23..=0x7F)))
.then_some(label)
}

// ───────────────────────────── Token ─────────────────────────────

#[derive(Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)]
Expand Down
2 changes: 1 addition & 1 deletion src/css/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ pub use dependencies::Dependency;
// for css_jsc / bundler.
pub use css_parser::{
DefaultAtRule, LocalsResultsMap, MinifyOptions, Parser, ParserFlags, ParserInput,
ParserOptions, StyleAttribute, StyleSheet, StylesheetExtra, ToCssResult,
ParserOptions, StyleAttribute, StyleSheet, StylesheetExtra, ToCssResult, warn_invalid_utf8,
};
pub use printer::{ImportInfo, Printer, PrinterOptions, PseudoClasses};
/// Dependent crates name this `ImportRecordHandler`; the surviving type is
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/cli/pm_diff_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,10 @@ fn normalize_css(path: &[u8], bytes: &[u8]) -> Option<Normalized> {
static ARENA: std::sync::LazyLock<Arena> = std::sync::LazyLock::new(Arena::new);
static FED: core::sync::atomic::AtomicUsize = core::sync::atomic::AtomicUsize::new(0);
const CSS_BUDGET: usize = 64 * 1024 * 1024;
if FED.fetch_add(bytes.len(), core::sync::atomic::Ordering::Relaxed) + bytes.len() > CSS_BUDGET
// The CSS tokenizer requires well-formed UTF-8; anything else diffs as text.
if !bun_core::strings::is_valid_utf8(bytes)
Comment thread
robobun marked this conversation as resolved.
Resolved
|| FED.fetch_add(bytes.len(), core::sync::atomic::Ordering::Relaxed) + bytes.len()
> CSS_BUDGET
{
Comment thread
robobun marked this conversation as resolved.
Resolved
return None;
}
Expand Down
11 changes: 11 additions & 0 deletions test/cli/install/bun-pm-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,17 @@ describe.concurrent("bun pm diff (engine invariants)", () => {
expect(exitCode).toBe(0);
});

test("CSS that is not valid UTF-8 is diffed as text, so a change to one ill-formed byte still shows", async () => {
// The CSS parser only takes valid UTF-8. A lossy decode maps 0xE9 and 0xE8 to the same U+FFFD, so the two
// re-prints would be equal and the change would read as "formatting only".
const sheet = (byte: number) =>
Buffer.concat([Buffer.from('.a::before { content: "caf'), Buffer.from([byte]), Buffer.from('"; }\n')]);
const { text, exitCode } = await pretty({ "a/latin1.css": sheet(0xe9), "b/latin1.css": sheet(0xe8) });
expect(text).toMatch(/\nlatin1\.css ─+ not parsed \+1 -1\n/);
expect(text).not.toContain("formatting only");
expect(exitCode).toBe(0);
});

// A debug build byte-scans the 64 MB line slowly (~30 s); release is well under a second.
test.skipIf(isDebug)(
"a file over the normalization size limit is diffed as text and says so",
Expand Down
72 changes: 0 additions & 72 deletions test/js/bun/css/invalid-utf8-column.test.ts

This file was deleted.

Loading
Loading