aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/fallback.rs79
-rw-r--r--src/platform/linux.rs6
-rw-r--r--src/platform/macos.rs6
-rw-r--r--src/platform/mod.rs16
-rw-r--r--src/platform/unix_common.rs131
-rw-r--r--src/platform/windows.rs201
-rw-r--r--src/platform/windows/clipboard_image.rs409
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));
+ }
+}