diff options
Diffstat (limited to 'src/platform')
| -rw-r--r-- | src/platform/fallback.rs | 79 | ||||
| -rw-r--r-- | src/platform/linux.rs | 6 | ||||
| -rw-r--r-- | src/platform/macos.rs | 6 | ||||
| -rw-r--r-- | src/platform/mod.rs | 16 | ||||
| -rw-r--r-- | src/platform/unix_common.rs | 131 | ||||
| -rw-r--r-- | src/platform/windows.rs | 201 | ||||
| -rw-r--r-- | src/platform/windows/clipboard_image.rs | 409 |
7 files changed, 837 insertions, 11 deletions
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 30bea5eb..f6380df8 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -3,6 +3,85 @@ use std::process::Command; use super::{ClipboardImage, ForegroundJob, Signal}; +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: None, + multiplexing: false, + } +} + +pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result<PathBuf> { + for attempt in 0..100 { + let dir = std::env::temp_dir().join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + match create_remote_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +pub(crate) fn create_remote_ssh_config_file( + path: &std::path::Path, +) -> std::io::Result<std::fs::File> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + +pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + std::env::temp_dir() +} + +pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, _short_name: &str) -> PathBuf { + std::env::temp_dir().join(readable_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + shell_quote(program) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + shell_quote(value) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + /// Unsupported platform stub. pub fn raise_server_nofile_limit() {} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 76568ad0..7932ae09 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -12,6 +12,12 @@ use super::{ LimitedRead, Signal, }; +pub(crate) use super::unix_common::{ + create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, + remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, + remote_reattach_program, remote_ssh_config_paths, +}; + const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION"; const CHILD_GROUPS_SCAN_LIMIT: usize = 64; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e9c89ede..fdb1e2eb 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -12,6 +12,12 @@ use super::{ LimitedRead, Signal, }; +pub(crate) use super::unix_common::{ + create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, + remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, + remote_reattach_program, remote_ssh_config_paths, +}; + const PROC_PGRP_ONLY: u32 = 2; const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 38d797d1..d8613be4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -52,7 +52,6 @@ fn configure_background_command_platform(_command: &mut std::process::Command) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlatformCapabilities { pub(crate) live_handoff: bool, - pub(crate) remote_attach: bool, pub(crate) direct_terminal_attach: bool, pub(crate) preserve_legacy_doubled_escape_input: bool, } @@ -60,7 +59,6 @@ pub(crate) struct PlatformCapabilities { pub(crate) const fn capabilities() -> PlatformCapabilities { PlatformCapabilities { live_handoff: cfg!(unix), - remote_attach: cfg!(unix), direct_terminal_attach: cfg!(unix), preserve_legacy_doubled_escape_input: cfg!(target_os = "macos"), } @@ -137,14 +135,11 @@ pub struct ClipboardCommand { } #[derive(Debug, Clone, PartialEq, Eq)] -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub struct ClipboardImage { pub bytes: Vec<u8>, pub extension: &'static str, } -#[cfg(unix)] #[derive(Debug, PartialEq, Eq)] pub(crate) enum LimitedRead { Empty, @@ -152,7 +147,6 @@ pub(crate) enum LimitedRead { Oversized, } -#[cfg(unix)] pub(crate) fn read_limited_reader( mut reader: impl std::io::Read, max_bytes: usize, @@ -190,6 +184,16 @@ pub(crate) fn read_limited_reader( } } +#[derive(Debug, Clone)] +pub(crate) struct RemoteSshConfigPaths { + pub(crate) user_config: Option<std::path::PathBuf>, + pub(crate) system_config: Option<std::path::PathBuf>, + pub(crate) multiplexing: bool, +} + +#[cfg(unix)] +mod unix_common; + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs new file mode 100644 index 00000000..754e2fc2 --- /dev/null +++ b/src/platform/unix_common.rs @@ -0,0 +1,131 @@ +use std::path::{Path, PathBuf}; + +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: Some(PathBuf::from("/etc/ssh/ssh_config")), + multiplexing: true, + } +} + +pub(crate) fn create_remote_ssh_config_dir(control_socket_name: &str) -> std::io::Result<PathBuf> { + use std::os::unix::fs::DirBuilderExt; + + let mut bases = vec![std::env::temp_dir()]; + let short_tmp = PathBuf::from("/tmp"); + if bases.first() != Some(&short_tmp) { + bases.push(short_tmp); + } + + let mut last_error = None; + let mut path_fits = false; + for base in bases { + for attempt in 0..100 { + let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + if !fits_unix_socket_path(&dir.join(control_socket_name)) { + continue; + } + path_fits = true; + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + last_error = Some(err); + break; + } + } + } + } + + if let Some(err) = last_error { + return Err(err); + } + let message = if path_fits { + "failed to create private herdr ssh config directory" + } else { + "SSH control socket path exceeds the Unix socket length limit" + }; + Err(std::io::Error::new( + if path_fits { + std::io::ErrorKind::AlreadyExists + } else { + std::io::ErrorKind::InvalidInput + }, + message, + )) +} + +pub(crate) fn create_remote_ssh_config_file(path: &Path) -> std::io::Result<std::fs::File> { + use std::os::unix::fs::OpenOptionsExt; + + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +pub(crate) fn create_remote_private_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + + std::fs::DirBuilder::new().mode(0o700).create(path) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + std::env::temp_dir() +} + +pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, short_name: &str) -> PathBuf { + let tmp = std::env::temp_dir(); + let readable = tmp.join(readable_name); + if fits_unix_socket_path(&readable) { + return readable; + } + let short = tmp.join(short_name); + if fits_unix_socket_path(&short) { + return short; + } + PathBuf::from("/tmp").join(short_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + shell_quote(if program.is_empty() { "herdr" } else { program }) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + shell_quote(value) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn fits_unix_socket_path(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().len() <= 103 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_ssh_config_dir_rejects_overlong_control_socket_name() { + let err = create_remote_ssh_config_dir(&"x".repeat(200)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 55e0fd0b..0c9971fb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -12,6 +12,8 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +mod clipboard_image; + use windows_sys::{ Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, Win32::{ @@ -20,9 +22,14 @@ use windows_sys::{ NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, + Security::SECURITY_ATTRIBUTES, + Storage::FileSystem::CreateDirectoryW, System::{ Console::GetConsoleWindow, - DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}, + DataExchange::{ + CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, + RegisterClipboardFormatW, SetClipboardData, + }, Diagnostics::{ Debug::ReadProcessMemory, ToolHelp::{ @@ -35,10 +42,10 @@ use windows_sys::{ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }, Memory::{ - GlobalAlloc, GlobalLock, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, + GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, MEMORY_BASIC_INFORMATION, }, - Ole::CF_UNICODETEXT, + Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT}, Threading::{ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS, @@ -83,6 +90,111 @@ static PROCESS_RUNTIME_MARKER_CACHE: LazyLock<Mutex<HashMap<u32, CachedProcessRu static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> = LazyLock::new(|| Mutex::new(HashMap::new())); +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .map(|dir| dir.join("ssh").join("ssh_config")), + multiplexing: false, + } +} + +pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result<PathBuf> { + let base = remote_private_temp_base(); + std::fs::create_dir_all(&base)?; + for attempt in 0..100 { + let dir = base.join(format!("ssh-{}-{attempt}", std::process::id())); + match create_remote_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +pub(crate) fn create_remote_ssh_config_file( + path: &std::path::Path, +) -> std::io::Result<std::fs::File> { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> { + use interprocess::os::windows::security_descriptor::{ + AsSecurityDescriptorExt as _, SecurityDescriptor, + }; + use widestring::U16CString; + + let sddl = U16CString::from_str("D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;OW)") + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; + let security_descriptor = SecurityDescriptor::deserialize(&sddl)?; + let mut security_attributes = SECURITY_ATTRIBUTES { + nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX), + lpSecurityDescriptor: null_mut(), + bInheritHandle: 0, + }; + security_descriptor.write_to_security_attributes(&mut security_attributes); + let path = extended_length_path(path)?; + if unsafe { CreateDirectoryW(path.as_ptr(), &security_attributes) } != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +fn extended_length_path(path: &std::path::Path) -> std::io::Result<Vec<u16>> { + use std::os::windows::ffi::OsStrExt as _; + + let path = std::path::absolute(path)?; + let wide = path.as_os_str().encode_wide().collect::<Vec<_>>(); + let mut extended = if wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]) + || wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'.' as u16, b'\\' as u16]) + { + wide + } else if wide.starts_with(&[b'\\' as u16, b'\\' as u16]) { + "\\\\?\\UNC\\" + .encode_utf16() + .chain(wide.into_iter().skip(2)) + .collect() + } else { + "\\\\?\\".encode_utf16().chain(wide).collect() + }; + extended.push(0); + Ok(extended) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + crate::config::state_dir().join("remote") +} + +pub(crate) fn remote_bridge_endpoint_path(_readable_name: &str, short_name: &str) -> PathBuf { + remote_private_temp_base().join(short_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + let path = std::env::current_exe() + .ok() + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| PathBuf::from(program)); + format!( + "& {}", + remote_reattach_argument(&path.display().to_string()) + ) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + /// Encode native or targeted semantic Win32 input for a compatible ConPTY destination. pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; @@ -1242,12 +1354,76 @@ pub fn open_url(url: &str) -> std::io::Result<()> { } } -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub fn read_clipboard_image() -> Option<ClipboardImage> { + for attempt in 0..10 { + if unsafe { OpenClipboard(null_mut()) } != 0 { + let _clipboard = ClipboardGuard; + if let Some(bytes) = read_registered_png_clipboard() { + return Some(ClipboardImage { + bytes, + extension: "png", + }); + } + for format in [CF_DIBV5 as u32, CF_DIB as u32] { + if let Some(bytes) = + clipboard_global_bytes(format, clipboard_image::MAX_CLIPBOARD_ALLOCATION) + { + if let Some(bytes) = clipboard_image::dib_to_png(&bytes) { + return Some(ClipboardImage { + bytes, + extension: "png", + }); + } + } + } + return None; + } + if attempt < 9 { + std::thread::sleep(Duration::from_millis(5)); + } + } None } +fn read_registered_png_clipboard() -> Option<Vec<u8>> { + static PNG_FORMAT: LazyLock<u32> = LazyLock::new(|| { + let name = wide_null("PNG"); + unsafe { RegisterClipboardFormatW(name.as_ptr()) } + }); + if *PNG_FORMAT == 0 { + return None; + } + let bytes = clipboard_global_bytes( + *PNG_FORMAT, + crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD + 64 * 1024, + )?; + clipboard_image::validated_png(&bytes) +} + +fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option<Vec<u8>> { + let handle = unsafe { GetClipboardData(format) }; + if handle.is_null() { + return None; + } + let data = unsafe { GlobalLock(handle) }; + if data.is_null() { + return None; + } + let size = unsafe { GlobalSize(handle) }; + if size == 0 || size > max_bytes { + unsafe { + GlobalUnlock(handle); + } + return None; + } + let mut bytes = vec![0_u8; size]; + unsafe { + copy_nonoverlapping(data.cast::<u8>(), bytes.as_mut_ptr(), size); + GlobalUnlock(handle); + } + Some(bytes) +} + pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> { let title = title.to_owned(); let body = body.unwrap_or(&title).to_owned(); @@ -1788,6 +1964,21 @@ mod tests { }; #[test] + fn private_remote_directory_supports_long_paths() { + let base = std::env::temp_dir().join(format!( + "herdr-private-remote-dir-test-{}", + std::process::id() + )); + fs::create_dir_all(&base).expect("create test base"); + let private = base.join("x".repeat(240)); + + super::create_remote_private_dir(&private).expect("create private long-path directory"); + fs::write(private.join("probe"), b"ok").expect("write inherited private file"); + + fs::remove_dir_all(base).expect("remove test directory"); + } + + #[test] fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() { let key = crate::input::TerminalKey::new( crossterm::event::KeyCode::Esc, diff --git a/src/platform/windows/clipboard_image.rs b/src/platform/windows/clipboard_image.rs new file mode 100644 index 00000000..f8e17aed --- /dev/null +++ b/src/platform/windows/clipboard_image.rs @@ -0,0 +1,409 @@ +use std::io::{self, Cursor, Write as _}; + +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; + +pub(super) const MAX_CLIPBOARD_ALLOCATION: usize = 64 * 1024 * 1024; + +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_IMAGE_PIXELS: usize = 16 * 1024 * 1024; +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +const BI_ALPHABITFIELDS: u32 = 6; + +pub(super) fn validated_png(bytes: &[u8]) -> Option<Vec<u8>> { + let logical_len = png_logical_len(bytes)?; + if logical_len > MAX_CLIPBOARD_IMAGE_PAYLOAD { + return None; + } + let bytes = &bytes[..logical_len]; + let mut decoder = png::Decoder::new_with_limits( + Cursor::new(bytes), + png::Limits { + bytes: MAX_CLIPBOARD_IMAGE_PAYLOAD, + }, + ); + decoder.set_ignore_text_chunk(true); + decoder.set_ignore_iccp_chunk(true); + let mut reader = decoder.read_info().ok()?; + validate_dimensions(reader.info().width, reader.info().height)?; + if reader.info().animation_control.is_some() { + return None; + } + while reader.next_interlaced_row().ok()?.is_some() {} + reader.finish().ok()?; + Some(bytes.to_vec()) +} + +fn png_logical_len(bytes: &[u8]) -> Option<usize> { + if !bytes.starts_with(PNG_SIGNATURE) { + return None; + } + let mut offset = PNG_SIGNATURE.len(); + while offset < bytes.len() { + let length = usize::try_from(read_u32_be(bytes, offset)?).ok()?; + let kind_start = offset.checked_add(4)?; + let data_start = kind_start.checked_add(4)?; + let data_end = data_start.checked_add(length)?; + let chunk_end = data_end.checked_add(4)?; + if chunk_end > bytes.len() { + return None; + } + if bytes.get(kind_start..data_start)? == b"IEND" { + return (length == 0).then_some(chunk_end); + } + offset = chunk_end; + } + None +} + +pub(super) fn dib_to_png(bytes: &[u8]) -> Option<Vec<u8>> { + Dib::parse(bytes)?.encode_png() +} + +struct Dib<'a> { + width: u32, + height: u32, + top_down: bool, + stride: usize, + pixels: &'a [u8], + format: PixelFormat, +} + +#[derive(Clone, Copy)] +enum PixelFormat { + Bgr24, + Bgrx32, + Masked32 { + red: ChannelMask, + green: ChannelMask, + blue: ChannelMask, + alpha: Option<ChannelMask>, + }, +} + +#[derive(Clone, Copy)] +struct ChannelMask { + mask: u32, + shift: u32, + maximum: u32, +} + +impl ChannelMask { + fn parse(mask: u32) -> Option<Self> { + if mask == 0 { + return None; + } + let shift = mask.trailing_zeros(); + let maximum = mask >> shift; + if maximum & maximum.wrapping_add(1) != 0 { + return None; + } + Some(Self { + mask, + shift, + maximum, + }) + } + + fn extract(self, pixel: u32) -> u8 { + let value = (pixel & self.mask) >> self.shift; + ((u64::from(value) * 255 + u64::from(self.maximum) / 2) / u64::from(self.maximum)) as u8 + } +} + +impl<'a> Dib<'a> { + fn parse(bytes: &'a [u8]) -> Option<Self> { + let header_size = usize::try_from(read_u32_le(bytes, 0)?).ok()?; + if !matches!(header_size, 40 | 52 | 56 | 108 | 124) || bytes.len() < header_size { + return None; + } + let signed_width = read_i32_le(bytes, 4)?; + let signed_height = read_i32_le(bytes, 8)?; + if signed_width <= 0 || signed_height == 0 || signed_height == i32::MIN { + return None; + } + let width = u32::try_from(signed_width).ok()?; + let height = signed_height.unsigned_abs(); + validate_dimensions(width, height)?; + if read_u16_le(bytes, 12)? != 1 { + return None; + } + + let bit_count = read_u16_le(bytes, 14)?; + let compression = read_u32_le(bytes, 16)?; + let colors_used = usize::try_from(read_u32_le(bytes, 32)?).ok()?; + let (format, external_mask_bytes) = + pixel_format(bytes, header_size, bit_count, compression)?; + let palette_bytes = colors_used.checked_mul(4)?; + let pixel_offset = header_size + .checked_add(external_mask_bytes)? + .checked_add(palette_bytes)?; + let row_bits = usize::try_from(width) + .ok()? + .checked_mul(usize::from(bit_count))?; + let stride = row_bits.checked_add(31)?.checked_div(32)?.checked_mul(4)?; + let image_size = stride.checked_mul(usize::try_from(height).ok()?)?; + let pixels = bytes.get(pixel_offset..pixel_offset.checked_add(image_size)?)?; + + Some(Self { + width, + height, + top_down: signed_height < 0, + stride, + pixels, + format, + }) + } + + fn encode_png(&self) -> Option<Vec<u8>> { + let mut output = LimitedWriter::new(MAX_CLIPBOARD_IMAGE_PAYLOAD); + { + let mut encoder = png::Encoder::new(&mut output, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().ok()?; + let mut stream = writer.stream_writer().ok()?; + let mut rgba = vec![0_u8; usize::try_from(self.width).ok()?.checked_mul(4)?]; + for output_row in 0..usize::try_from(self.height).ok()? { + let source_row = if self.top_down { + output_row + } else { + usize::try_from(self.height) + .ok()? + .checked_sub(output_row + 1)? + }; + let start = source_row.checked_mul(self.stride)?; + let source = self.pixels.get(start..start.checked_add(self.stride)?)?; + self.decode_row(source, &mut rgba)?; + stream.write_all(&rgba).ok()?; + } + stream.finish().ok()?; + writer.finish().ok()?; + } + Some(output.into_inner()) + } + + fn decode_row(&self, source: &[u8], output: &mut [u8]) -> Option<()> { + match self.format { + PixelFormat::Bgr24 => { + for (pixel, rgba) in source + .chunks_exact(3) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]); + } + } + PixelFormat::Bgrx32 => { + for (pixel, rgba) in source + .chunks_exact(4) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]); + } + } + PixelFormat::Masked32 { + red, + green, + blue, + alpha, + } => { + for (pixel, rgba) in source + .chunks_exact(4) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + let pixel = u32::from_le_bytes(pixel.try_into().ok()?); + rgba.copy_from_slice(&[ + red.extract(pixel), + green.extract(pixel), + blue.extract(pixel), + alpha.map_or(255, |mask| mask.extract(pixel)), + ]); + } + } + } + Some(()) + } +} + +fn pixel_format( + bytes: &[u8], + header_size: usize, + bit_count: u16, + compression: u32, +) -> Option<(PixelFormat, usize)> { + match (bit_count, compression) { + (24, BI_RGB) => return Some((PixelFormat::Bgr24, 0)), + (32, BI_RGB) => return Some((PixelFormat::Bgrx32, 0)), + (32, BI_BITFIELDS | BI_ALPHABITFIELDS) => {} + _ => return None, + } + + let (offset, external_mask_bytes) = if header_size == 40 { + let count = if compression == BI_ALPHABITFIELDS { + 4 + } else { + 3 + }; + (40, count * 4) + } else { + if header_size < 52 || (compression == BI_ALPHABITFIELDS && header_size < 56) { + return None; + } + (40, 0) + }; + let red = ChannelMask::parse(read_u32_le(bytes, offset)?)?; + let green = ChannelMask::parse(read_u32_le(bytes, offset + 4)?)?; + let blue = ChannelMask::parse(read_u32_le(bytes, offset + 8)?)?; + if red.mask & green.mask != 0 || red.mask & blue.mask != 0 || green.mask & blue.mask != 0 { + return None; + } + let alpha = if compression == BI_ALPHABITFIELDS || header_size >= 56 { + match read_u32_le(bytes, offset + 12).filter(|mask| *mask != 0) { + Some(mask) => Some(ChannelMask::parse(mask)?), + None => None, + } + } else { + None + }; + if alpha.is_some_and(|alpha| alpha.mask & (red.mask | green.mask | blue.mask) != 0) { + return None; + } + Some(( + PixelFormat::Masked32 { + red, + green, + blue, + alpha, + }, + external_mask_bytes, + )) +} + +fn validate_dimensions(width: u32, height: u32) -> Option<()> { + if width == 0 || height == 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION { + return None; + } + let pixels = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + (pixels <= MAX_IMAGE_PIXELS).then_some(()) +} + +struct LimitedWriter { + bytes: Vec<u8>, + limit: usize, +} + +impl LimitedWriter { + fn new(limit: usize) -> Self { + Self { + bytes: Vec::new(), + limit, + } + } + + fn into_inner(self) -> Vec<u8> { + self.bytes + } +} + +impl io::Write for LimitedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { + if bytes.len() > self.limit.saturating_sub(self.bytes.len()) { + return Err(io::Error::new( + io::ErrorKind::FileTooLarge, + "encoded clipboard image exceeds protocol limit", + )); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn read_u16_le(bytes: &[u8], offset: usize) -> Option<u16> { + Some(u16::from_le_bytes( + bytes.get(offset..offset + 2)?.try_into().ok()?, + )) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_i32_le(bytes: &[u8], offset: usize) -> Option<i32> { + Some(i32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_u32_be(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_be_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info_header(width: i32, height: i32, bit_count: u16, compression: u32) -> Vec<u8> { + let mut header = vec![0_u8; 40]; + header[0..4].copy_from_slice(&40_u32.to_le_bytes()); + header[4..8].copy_from_slice(&width.to_le_bytes()); + header[8..12].copy_from_slice(&height.to_le_bytes()); + header[12..14].copy_from_slice(&1_u16.to_le_bytes()); + header[14..16].copy_from_slice(&bit_count.to_le_bytes()); + header[16..20].copy_from_slice(&compression.to_le_bytes()); + header + } + + fn decode_rgba(bytes: &[u8]) -> Vec<u8> { + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().unwrap(); + let mut output = vec![0_u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut output).unwrap(); + output.truncate(info.buffer_size()); + output + } + + #[test] + fn converts_bottom_up_24_bit_dib_to_png() { + let mut dib = info_header(2, 2, 24, BI_RGB); + dib.extend_from_slice(&[ + 255, 0, 0, 255, 255, 255, 0, 0, // bottom: blue, white, padding + 0, 0, 255, 0, 255, 0, 0, 0, // top: red, green, padding + ]); + + let png = dib_to_png(&dib).unwrap(); + assert_eq!( + decode_rgba(&png), + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,] + ); + } + + #[test] + fn strips_registered_png_allocator_tail() { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[1, 2, 3, 255]).unwrap(); + writer.finish().unwrap(); + let expected = bytes.clone(); + bytes.extend_from_slice(&[0; 32]); + + assert_eq!(validated_png(&bytes), Some(expected)); + } +} |
