diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 27f6012c8bc7..5ccccf14215a 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -2792,6 +2792,13 @@ pub fn buffered_stdin_read_until_delimiter( unsafe { (*buffered_stdin()).read_until_delimiter_array_list(out, delimiter, max_size) } } +static FIRST_DIALOG_READ: AtomicBool = AtomicBool::new(true); + +/// `true` once per process, at the first read of stdin by `alert()`, `confirm()` or `prompt()`. A UTF-8 byte order mark there is the signature of the writer. +pub fn take_first_dialog_read() -> bool { + FIRST_DIALOG_READ.swap(false, Ordering::SeqCst) +} + /// https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 pub const SYNCHRONIZED_START: &str = "\x1b[?2026h"; diff --git a/src/runtime/webcore/prompt.rs b/src/runtime/webcore/prompt.rs index 8901aa958e22..1f0fe341bbd5 100644 --- a/src/runtime/webcore/prompt.rs +++ b/src/runtime/webcore/prompt.rs @@ -4,6 +4,7 @@ use crate::webcore::jsc::{JSValue, JsResult}; use bun_collections::VecExt as _; use bun_core::EncodedSlice; use bun_core::Output; +use bun_core::strings; use bun_jsc::EncodedSliceJsc as _; use bun_jsc::bindgen::{JsLocal, JsScope}; @@ -52,11 +53,33 @@ pub(crate) fn alert(message: Option<&[u8]>) { break; } } + // alert() is a dialog read too, so a byte order mark on the line after it stays. + Output::take_first_dialog_read(); // 8. Invoke WebDriver BiDi user prompt closed with this and true. // * Again, not necessary in a server context. } +/// Returns the first byte of the answer, or `None` when the line or stdin ended first. +fn skip_stdin_byte_order_mark( + reader: &mut bun_core::output::StdinReader, + first_byte: u8, +) -> Option { + let [first_of_mark, rest_of_mark @ ..] = strings::BOM::UTF8_BYTES; + if !Output::take_first_dialog_read() || first_byte != first_of_mark { + return Some(first_byte); + } + for mark_byte in rest_of_mark { + match reader.take_byte() { + Ok(byte) if byte == mark_byte => {} + Ok(b'\n' | b'\r') | Err(_) => return None, + // Not a mark, and not `y` either: the caller reads to the end of the line and answers no. + Ok(_) => return Some(first_byte), + } + } + reader.take_byte().ok() +} + pub(crate) fn confirm(message: Option<&[u8]>) -> bool { let output = Output::writer(); @@ -97,6 +120,9 @@ pub(crate) fn confirm(message: Option<&[u8]>) -> bool { let Ok(first_byte) = reader.take_byte() else { return false; }; + let Some(first_byte) = skip_stdin_byte_order_mark(&mut reader, first_byte) else { + return false; + }; // 7. Invoke WebDriver BiDi user prompt closed with this, and true if // the user responded positively or false otherwise. @@ -295,6 +321,7 @@ pub(crate) fn prompt<'a>( // that the user responded with. return Ok(JSValue::NULL); }; + let first_dialog_read = Output::take_first_dialog_read(); if first_byte == b'\n' { // 8. Let result be null if the user aborts, or otherwise the string @@ -361,9 +388,18 @@ pub(crate) fn prompt<'a>( debug_assert!(!input.is_empty()); + let answer = if first_dialog_read { + strings::without_utf8_bom(&input) + } else { + input.as_slice() + }; + if answer.is_empty() { + return Ok(default.unwrap_or(JSValue::NULL)); + } + // 8. Let result be null if the user aborts, or otherwise the string // that the user responded with. - let result = EncodedSlice::utf8(&input); + let result = EncodedSlice::utf8(answer); // 9. Invoke WebDriver BiDi user prompt closed with this, false if // result is null or true otherwise, and result. diff --git a/test/js/web/web-globals.test.js b/test/js/web/web-globals.test.js index d83ba557f3f4..4c9e10ad40eb 100644 --- a/test/js/web/web-globals.test.js +++ b/test/js/web/web-globals.test.js @@ -355,6 +355,73 @@ test.concurrent.each(["userAgent", "platform", "hardwareConcurrency"])( }, ); +// A UTF-8 byte order mark (EF BB BF) is the encoding signature of the program that wrote stdin. The dialogs drop +// one mark, at the first bytes that a dialog reads in the process. A U+FEFF that a dialog reads later stays. +describe.concurrent("byte order mark at the first dialog read of stdin", () => { + const mark = "\uFEFF"; + // U+FEFF is invisible, so the child and the expectation both spell it out. + const spelled = results => JSON.stringify(results).replaceAll(mark, ""); + const expected = results => ({ stderr: spelled(results) + "\n", exitCode: 0 }); + + /** Runs `calls`, an array literal of dialog calls, in a fresh process: the first dialog read comes once per process. */ + async function run(calls, stdin) { + await using proc = spawn({ + cmd: [bunExe(), "-e", `console.error(JSON.stringify(${calls}).replaceAll("\\uFEFF", ""))`], + stdin: Buffer.from(stdin), + stdout: "ignore", + stderr: "pipe", + env: bunEnv, + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + } + + test.each([ + ["confirm: y", `[confirm("ok?")]`, mark + "y\n", [true]], + ["confirm: Y, windows newline", `[confirm("ok?")]`, mark + "Y\r\n", [true]], + ["confirm: only one mark is removed", `[confirm("ok?")]`, mark + mark + "y\n", [false]], + ["confirm: empty line, windows newline", `[confirm("a"), confirm("b")]`, mark + "\r\ny\n", [false, true]], + ["prompt: answer", `[prompt("q")]`, mark + "yes\n", ["yes"]], + ["prompt: answer, windows newline", `[prompt("q")]`, mark + "yes\r\n", ["yes"]], + ["prompt: empty line gives the default", `[prompt("q", "dflt")]`, mark + "\n", ["dflt"]], + ["prompt: empty line, windows newline", `[prompt("q", "dflt")]`, mark + "\r\n", ["dflt"]], + ["prompt: only one mark is removed", `[prompt("q")]`, mark + mark + "x\n", [mark + "x"]], + ["confirm, then prompt", `[confirm("ok?"), prompt("q")]`, mark + "y\n" + mark + "b\n", [true, mark + "b"]], + ["prompt twice", `[prompt("a"), prompt("b")]`, mark + "a\n" + mark + "b\n", ["a", mark + "b"]], + ["confirm leaves the next line", `[confirm("ok?"), await Bun.stdin.text()]`, mark + "y\nrest\n", [true, "rest\n"]], + ["alert takes the mark with its line", `[alert("a"), confirm("ok?")]`, mark + "\ny\n", [null, true]], + ["confirm: the mark, end of stdin", `[confirm("ok?")]`, mark, [false]], + ["prompt: the mark, end of stdin", `[prompt("q", "dflt")]`, mark, [null]], + ])("the first answer does not include it (%s)", async (_, calls, stdin, results) => { + expect(await run(calls, stdin)).toEqual(expected(results)); + }); + + test.each([ + ["after a prompt", `[prompt("a"), prompt("b")]`, "a\n" + mark + "b\n", ["a", mark + "b"]], + ["after an empty prompt", `[prompt("a", "dflt"), prompt("b")]`, "\n" + mark + "b\n", ["dflt", mark + "b"]], + ["after a confirm", `[confirm("ok?"), prompt("q")]`, "y\n" + mark + "b\n", [true, mark + "b"]], + ["after an empty confirm", `[confirm("a"), confirm("b")]`, "\n" + mark + "y\n", [false, false]], + ["after an alert", `[alert("a"), confirm("ok?")]`, "\n" + mark + "y\n", [null, false]], + ["after the answer", `[prompt("q")]`, "a" + mark + "\n", ["a" + mark]], + ])("a U+FEFF that is not at the start stays (%s)", async (_, calls, stdin, results) => { + expect(await run(calls, stdin)).toEqual(expected(results)); + }); + + // Every character from U+F000 to U+FFFF starts with the byte EF. U+FF7F is EF BD BF, U+FEC0 is EF BB 80. + test.each([ + ["confirm: EF, newline", `[confirm("a"), confirm("b")]`, [0xef, 0x0a, 0x79, 0x0a], [false, true]], + ["confirm: EF BB, newline", `[confirm("a"), confirm("b")]`, [0xef, 0xbb, 0x0a, 0x79, 0x0a], [false, true]], + // A rejected line stops at CR, as it does without a mark: the LF is the empty line of the next dialog. + ["confirm: EF, windows newline", `[confirm("a"), confirm("b")]`, [0xef, 0x0d, 0x0a, 0x79, 0x0a], [false, false]], + ["confirm: U+FF7F, then y", `[confirm("ok?"), await Bun.stdin.text()]`, "\uFF7Fy\nrest\n", [false, "rest\n"]], + ["confirm: U+FEC0, then y", `[confirm("ok?"), await Bun.stdin.text()]`, "\uFEC0y\nrest\n", [false, "rest\n"]], + ["confirm: EF, end of stdin", `[confirm("ok?")]`, [0xef], [false]], + ["prompt: EF BB, newline", `[prompt("a"), prompt("b")]`, [0xef, 0xbb, 0x0a, 0x62, 0x0a], ["\uFFFD", "b"]], + ])("bytes that only begin like it are input (%s)", async (_, calls, stdin, results) => { + expect(await run(calls, stdin)).toEqual(expected(results)); + }); +}); + test("confirm (yes) unix newline", async () => { const proc = spawn({ cmd: [bunExe(), require("path").join(import.meta.dir, "./confirm-fixture.js")],