diff --git a/crates/wasi-common/src/blockless/prompter.rs b/crates/wasi-common/src/blockless/prompter.rs new file mode 100644 index 0000000..5838487 --- /dev/null +++ b/crates/wasi-common/src/blockless/prompter.rs @@ -0,0 +1,418 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use super::colors; +use std::fmt::Write; +use std::io::BufRead; +use std::io::IsTerminal; +use std::io::StderrLock; +use std::io::StdinLock; +use std::io::Write as IoWrite; +use std::sync::Once; +use anyhow::bail; +use anyhow::Error as AnyError; + +use bls_permissions::is_standalone; +use bls_permissions::bls_set_prompt_callbacks; +use bls_permissions::bls_set_prompter; +pub use bls_permissions::PermissionPrompter; +pub use bls_permissions::PromptCallback; +pub use bls_permissions::PromptResponse; +pub use bls_permissions::MAX_PERMISSION_PROMPT_LENGTH; +pub use bls_permissions::PERMISSION_EMOJI; + +/// Helper function to make control characters visible so users can see the underlying filename. +fn escape_control_characters(s: &str) -> std::borrow::Cow { + if !s.contains(|c: char| c.is_ascii_control() || c.is_control()) { + return std::borrow::Cow::Borrowed(s); + } + let mut output = String::with_capacity(s.len() * 2); + for c in s.chars() { + match c { + c if c.is_ascii_control() => output + .push_str(&colors::white_bold_on_red(c.escape_debug().to_string()).to_string()), + c if c.is_control() => output + .push_str(&colors::white_bold_on_red(c.escape_debug().to_string()).to_string()), + c => output.push(c), + } + } + output.into() +} + +pub fn init_tty_prompter() { + static TTYPROMPTER: Once = Once::new(); + TTYPROMPTER.call_once(|| { + set_prompter(Box::new(TtyPrompter)); + }); +} + +pub fn set_prompt_callbacks(before_callback: PromptCallback, after_callback: PromptCallback) { + bls_set_prompt_callbacks(before_callback, after_callback); +} + +pub fn set_prompter(prompter: Box) { + bls_set_prompter(prompter); +} + +pub struct TtyPrompter; +#[cfg(unix)] +fn clear_stdin(_stdin_lock: &mut StdinLock, _stderr_lock: &mut StderrLock) -> Result<(), AnyError> { + use std::mem::MaybeUninit; + + const STDIN_FD: i32 = 0; + + // SAFETY: use libc to flush stdin + unsafe { + // Create fd_set for select + let mut raw_fd_set = MaybeUninit::::uninit(); + libc::FD_ZERO(raw_fd_set.as_mut_ptr()); + libc::FD_SET(STDIN_FD, raw_fd_set.as_mut_ptr()); + loop { + let r = libc::tcflush(STDIN_FD, libc::TCIFLUSH); + if r != 0 { + bail!("clear_stdin failed (tcflush)"); + } + + // Initialize timeout for select to be 100ms + let mut timeout = libc::timeval { + tv_sec: 0, + tv_usec: 100_000, + }; + + // Call select with the stdin file descriptor set + let r = libc::select( + STDIN_FD + 1, // nfds should be set to the highest-numbered file descriptor in any of the three sets, plus 1. + raw_fd_set.as_mut_ptr(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut timeout, + ); + + // Check if select returned an error + if r < 0 { + bail!("clear_stdin failed (select)"); + } + + // Check if select returned due to timeout (stdin is quiescent) + if r == 0 { + break; // Break out of the loop as stdin is quiescent + } + + // If select returned due to data available on stdin, clear it by looping around to flush + } + } + + Ok(()) +} + +#[cfg(not(unix))] +fn clear_stdin(stdin_lock: &mut StdinLock, stderr_lock: &mut StderrLock) -> Result<(), AnyError> { + use winapi::shared::minwindef::TRUE; + use winapi::shared::minwindef::UINT; + use winapi::shared::minwindef::WORD; + use winapi::shared::ntdef::WCHAR; + use winapi::um::processenv::GetStdHandle; + use winapi::um::winbase::STD_INPUT_HANDLE; + use winapi::um::wincon::FlushConsoleInputBuffer; + use winapi::um::wincon::PeekConsoleInputW; + use winapi::um::wincon::WriteConsoleInputW; + use winapi::um::wincontypes::INPUT_RECORD; + use winapi::um::wincontypes::KEY_EVENT; + use winapi::um::winnt::HANDLE; + use winapi::um::winuser::MapVirtualKeyW; + use winapi::um::winuser::MAPVK_VK_TO_VSC; + use winapi::um::winuser::VK_RETURN; + + // SAFETY: winapi calls + unsafe { + let stdin = GetStdHandle(STD_INPUT_HANDLE); + // emulate an enter key press to clear any line buffered console characters + emulate_enter_key_press(stdin)?; + // read the buffered line or enter key press + read_stdin_line(stdin_lock)?; + // check if our emulated key press was executed + if is_input_buffer_empty(stdin)? { + // if so, move the cursor up to prevent a blank line + move_cursor_up(stderr_lock)?; + } else { + // the emulated key press is still pending, so a buffered line was read + // and we can flush the emulated key press + flush_input_buffer(stdin)?; + } + } + + return Ok(()); + + unsafe fn flush_input_buffer(stdin: HANDLE) -> Result<(), AnyError> { + let success = FlushConsoleInputBuffer(stdin); + if success != TRUE { + bail!( + "Could not flush the console input buffer: {}", + std::io::Error::last_os_error() + ) + } + Ok(()) + } + + unsafe fn emulate_enter_key_press(stdin: HANDLE) -> Result<(), AnyError> { + let mut input_record: INPUT_RECORD = std::mem::zeroed(); + input_record.EventType = KEY_EVENT; + input_record.Event.KeyEvent_mut().bKeyDown = TRUE; + input_record.Event.KeyEvent_mut().wRepeatCount = 1; + input_record.Event.KeyEvent_mut().wVirtualKeyCode = VK_RETURN as WORD; + input_record.Event.KeyEvent_mut().wVirtualScanCode = + MapVirtualKeyW(VK_RETURN as UINT, MAPVK_VK_TO_VSC) as WORD; + *input_record.Event.KeyEvent_mut().uChar.UnicodeChar_mut() = '\r' as WCHAR; + + let mut record_written = 0; + let success = WriteConsoleInputW(stdin, &input_record, 1, &mut record_written); + if success != TRUE { + bail!( + "Could not emulate enter key press: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) + } + + unsafe fn is_input_buffer_empty(stdin: HANDLE) -> Result { + let mut buffer = Vec::with_capacity(1); + let mut events_read = 0; + let success = PeekConsoleInputW(stdin, buffer.as_mut_ptr(), 1, &mut events_read); + if success != TRUE { + bail!( + "Could not peek the console input buffer: {}", + std::io::Error::last_os_error() + ) + } + Ok(events_read == 0) + } + + fn move_cursor_up(stderr_lock: &mut StderrLock) -> Result<(), AnyError> { + write!(stderr_lock, "\x1B[1A")?; + Ok(()) + } + + fn read_stdin_line(stdin_lock: &mut StdinLock) -> Result<(), AnyError> { + let mut input = String::new(); + stdin_lock.read_line(&mut input)?; + Ok(()) + } +} + +// Clear n-lines in terminal and move cursor to the beginning of the line. +fn clear_n_lines(stderr_lock: &mut StderrLock, n: usize) { + write!(stderr_lock, "\x1B[{n}A\x1B[0J").unwrap(); +} + +#[cfg(unix)] +fn get_stdin_metadata() -> std::io::Result { + use std::os::fd::FromRawFd; + use std::os::fd::IntoRawFd; + + // SAFETY: we don't know if fd 0 is valid but metadata() will return an error in this case (bad file descriptor) + // and we can panic. + unsafe { + let stdin = std::fs::File::from_raw_fd(0); + let metadata = stdin.metadata().unwrap(); + let _ = stdin.into_raw_fd(); + Ok(metadata) + } +} + +impl PermissionPrompter for TtyPrompter { + fn prompt( + &mut self, + message: &str, + name: &str, + api_name: Option<&str>, + is_unary: bool, + ) -> PromptResponse { + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return PromptResponse::Deny; + }; + + #[allow(clippy::print_stderr)] + if message.len() > MAX_PERMISSION_PROMPT_LENGTH { + eprintln!("❌ Permission prompt length ({} bytes) was larger than the configured maximum length ({} bytes): denying request.", message.len(), MAX_PERMISSION_PROMPT_LENGTH); + eprintln!("❌ WARNING: This may indicate that code is trying to bypass or hide permission check requests."); + eprintln!("❌ Run again with --allow-{name} to bypass this check if this is really what you want to do."); + return PromptResponse::Deny; + } + + #[cfg(unix)] + let metadata_before = get_stdin_metadata().unwrap(); + + // Lock stdio streams, so no other output is written while the prompt is + // displayed. + let stdout_lock = std::io::stdout().lock(); + let mut stderr_lock = std::io::stderr().lock(); + let mut stdin_lock = std::io::stdin().lock(); + + // For security reasons we must consume everything in stdin so that previously + // buffered data cannot affect the prompt. + #[allow(clippy::print_stderr)] + if let Err(err) = clear_stdin(&mut stdin_lock, &mut stderr_lock) { + eprintln!("Error clearing stdin for permission prompt. {err:#}"); + return PromptResponse::Deny; // don't grant permission if this fails + } + + let message = escape_control_characters(message); + let name = escape_control_characters(name); + let api_name = api_name.map(escape_control_characters); + + // print to stderr so that if stdout is piped this is still displayed. + let opts: String = if is_unary { + format!("[y/n/A] (y = yes, allow; n = no, deny; A = allow all {name} permissions)") + } else { + "[y/n] (y = yes, allow; n = no, deny)".to_string() + }; + + // output everything in one shot to make the tests more reliable + { + let mut output = String::new(); + write!(&mut output, "┏ {PERMISSION_EMOJI} ").unwrap(); + write!(&mut output, "{}", colors::bold("Bls-Runtime requests ")).unwrap(); + write!(&mut output, "{}", colors::bold(message.clone())).unwrap(); + writeln!(&mut output, "{}", colors::bold(".")).unwrap(); + if let Some(api_name) = api_name.clone() { + writeln!( + &mut output, + "┠─ Requested by `{}` API.", + colors::bold(api_name) + ) + .unwrap(); + } + let msg = format!( + "Learn more at: {}", + colors::cyan_with_underline(&format!("https://docs.bless.network/go/--allow-{}", name)) + ); + writeln!(&mut output, "┠─ {}", colors::italic(&msg)).unwrap(); + let msg = if is_standalone() { + format!("Specify the required permissions during compile time using `bls-runtime compile --allow-{name}`.") + } else { + format!("Run again with --allow-{name} to bypass this prompt.") + }; + writeln!(&mut output, "┠─ {}", colors::italic(&msg)).unwrap(); + write!(&mut output, "┗ {}", colors::bold("Allow?")).unwrap(); + write!(&mut output, " {opts} > ").unwrap(); + + stderr_lock.write_all(output.as_bytes()).unwrap(); + } + + let value = loop { + // Clear stdin each time we loop around in case the user accidentally pasted + // multiple lines or otherwise did something silly to generate a torrent of + // input. This doesn't work on Windows because `clear_stdin` has other side-effects. + #[allow(clippy::print_stderr)] + #[cfg(unix)] + if let Err(err) = clear_stdin(&mut stdin_lock, &mut stderr_lock) { + eprintln!("Error clearing stdin for permission prompt. {err:#}"); + return PromptResponse::Deny; // don't grant permission if this fails + } + + let mut input = String::new(); + let result = stdin_lock.read_line(&mut input); + let input = input.trim_end_matches(['\r', '\n']); + if result.is_err() || input.len() != 1 { + break PromptResponse::Deny; + }; + match input.as_bytes()[0] as char { + 'y' | 'Y' => { + clear_n_lines(&mut stderr_lock, if api_name.is_some() { 5 } else { 4 }); + let msg = format!("Granted {message}."); + writeln!(stderr_lock, "✅ {}", colors::bold(&msg)).unwrap(); + break PromptResponse::Allow; + } + 'n' | 'N' | '\x1b' => { + clear_n_lines(&mut stderr_lock, if api_name.is_some() { 5 } else { 4 }); + let msg = format!("Denied {message}."); + writeln!(stderr_lock, "❌ {}", colors::bold(&msg)).unwrap(); + break PromptResponse::Deny; + } + 'A' if is_unary => { + clear_n_lines(&mut stderr_lock, if api_name.is_some() { 5 } else { 4 }); + let msg = format!("Granted all {name} access."); + writeln!(stderr_lock, "✅ {}", colors::bold(&msg)).unwrap(); + break PromptResponse::AllowAll; + } + _ => { + // If we don't get a recognized option try again. + clear_n_lines(&mut stderr_lock, 1); + write!( + stderr_lock, + "┗ {} {opts} > ", + colors::bold("Unrecognized option. Allow?") + ) + .unwrap(); + } + }; + }; + + drop(stdout_lock); + drop(stderr_lock); + drop(stdin_lock); + + // Ensure that stdin has not changed from the beginning to the end of the prompt. We consider + // it sufficient to check a subset of stat calls. We do not consider the likelihood of a stdin + // swap attack on Windows to be high enough to add this check for that platform. These checks will + // terminate the runtime as they indicate something nefarious is going on. + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let metadata_after = get_stdin_metadata().unwrap(); + + assert_eq!(metadata_before.dev(), metadata_after.dev()); + assert_eq!(metadata_before.ino(), metadata_after.ino()); + assert_eq!(metadata_before.rdev(), metadata_after.rdev()); + assert_eq!(metadata_before.uid(), metadata_after.uid()); + assert_eq!(metadata_before.gid(), metadata_after.gid()); + assert_eq!(metadata_before.mode(), metadata_after.mode()); + } + + // Ensure that stdin and stderr are still terminals before we yield the response. + assert!(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()); + + value + } +} + +#[cfg(test)] +pub mod tests { + use std::sync::Mutex; + use once_cell::sync::Lazy; + + use super::*; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + + pub struct TestPrompter; + + impl PermissionPrompter for TestPrompter { + fn prompt( + &mut self, + _message: &str, + _name: &str, + _api_name: Option<&str>, + _is_unary: bool, + ) -> PromptResponse { + if STUB_PROMPT_VALUE.load(Ordering::SeqCst) { + PromptResponse::Allow + } else { + PromptResponse::Deny + } + } + } + + static STUB_PROMPT_VALUE: AtomicBool = AtomicBool::new(true); + + pub static PERMISSION_PROMPT_STUB_VALUE_SETTER: Lazy> = + Lazy::new(|| Mutex::new(PermissionPromptStubValueSetter)); + + pub struct PermissionPromptStubValueSetter; + + impl PermissionPromptStubValueSetter { + pub fn set(&self, value: bool) { + STUB_PROMPT_VALUE.store(value, Ordering::SeqCst); + } + } +} \ No newline at end of file