Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/bun_core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
38 changes: 37 additions & 1 deletion src/runtime/webcore/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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();

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.

🟡 (optional) After an alert() that read nothing, a later confirm() on a line beginning with a byte order mark answers false, while the same sequence through confirm() first answers true. alert() at prompt.rs:61 consumes the flag unconditionally, even when its loop at prompt.rs:55 broke on an immediate Err (EOF, EAGAIN on a non-blocking fd, or a transient read failure), whereas confirm() (prompt.rs:131) and prompt() (prompt.rs:338) return before taking it. Fix: take the flag only once a dialog has actually consumed its first byte, in all three functions, so the mark skip applies to the first bytes a dialog reads.

Why this was flagged

Trigger: stdin is a terminal or a non-blocking fd (a parent or process.stdin can set O_NONBLOCK on fd 0, and take_byte at src/bun_core/output.rs:2747 returns Err on EAGAIN) and the script calls alert() then confirm(). alert()'s loop at src/runtime/webcore/prompt.rs:55 breaks on the Err without reading any byte, then prompt.rs:61 swaps the flag to false. confirm() then reads the actual first bytes of stdin, EF BB BF 79 0A, but prompt.rs:75 sees the flag already false and returns 0xEF, so the _ arm at prompt.rs:174 drains the line and answers false. With confirm() first, the same bytes answer true. The base branch answered false in both orders, so the new code is now order-dependent for the same input. The dismissal (argued, six finders) only considered a TTY Ctrl-D and ignored that the three dialogs take the flag at different points: confirm() and prompt() after a successful first byte, alert() regardless of success. Remedy: in alert(), take the flag only after at least one byte was read, matching the two siblings.

Verification: Triggered when fd 0 is non-blocking and the writer's bytes are not yet in the pipe when alert() runs; the following confirm() then answers false exactly as the base does. alert()'s loop at src/runtime/webcore/prompt.rs:54-59 breaks on the first Err without consuming anything, and line 61 then runs Output::take_first_dialog_read(); unconditionally. The new tests (test/js/web/web-globals.test.js:392, 404) only cover alert() consuming a real line.

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.

Correct. alert() calls take_first_dialog_read() after its read loop, also when the loop read no byte (src/runtime/webcore/prompt.rs:57). confirm() and prompt() return before the call when their first read fails, so there the flag stays for the next dialog.

The sequence is false on main too. I ran it on Bun 1.4.3. Stdin was a regular file that is empty at the first dialog and gets the mark and y after it. alert() and then confirm() give [null,false]. So this change makes nothing worse there. The sequence stays unrepaired when alert() is the dialog that reads nothing.

d4f10da only merges main, so alert() is unchanged. The description now states that alert() takes the flag also when it read no byte. The repair is to take the flag in alert() only after it read a byte. It rides the next push to this branch, if the maintainer's answer keeps the flag.


// 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<u8> {
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();

Expand Down Expand Up @@ -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 {
Comment thread
robobun marked this conversation as resolved.
Resolved
return false;
};

// 7. Invoke WebDriver BiDi user prompt closed with this, and true if
// the user responded positively or false otherwise.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions test/js/web/web-globals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<U+FEFF>");
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", "<U+FEFF>"))`],
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([
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
["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")],
Expand Down
Loading