diff --git a/apps/wordcraft/src/clipboard.rs b/apps/wordcraft/src/clipboard.rs index 7d00ca3892..e22f89bae3 100644 --- a/apps/wordcraft/src/clipboard.rs +++ b/apps/wordcraft/src/clipboard.rs @@ -95,7 +95,8 @@ pub fn formats() -> Vec { /// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded /// picture in a format arboard doesn't decode. A picture that is there but can't be read says -/// why (#833). The UI checks the sizes before inserting anything. +/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked +/// from the header before anything is decoded (#3623). pub fn picture() -> Option { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -109,6 +110,19 @@ pub fn picture() -> Option { { return Some(ClipboardPicture::Files(files)); } + decoded_picture(&mut clipboard) +} + +/// The clipboard's picture on Windows, read past arboard, which decodes the PNG and DIB formats +/// with no size limits (#3623). +#[cfg(target_os = "windows")] +fn decoded_picture(_: &mut arboard::Clipboard) -> Option { + platform::picture() +} + +/// The picture arboard decodes from the clipboard, else an encoded one read past it. +#[cfg(not(target_os = "windows"))] +fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option { match clipboard.get_image() { Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }), Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded), @@ -187,6 +201,7 @@ mod platform { #[cfg(target_os = "windows")] mod platform { use clipboard_win::{Clipboard, EnumFormats, raw}; + use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture}; pub fn formats() -> Vec { let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() }; @@ -205,8 +220,31 @@ mod platform { Some(out) } - pub fn encoded_picture() -> Option> { - None + /// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as + /// pixels, each refused from its header when a paste can't take its size (#3623). None when + /// it holds neither. + pub fn picture() -> Option { + use clipboard_win::formats::{CF_DIB, CF_DIBV5}; + let Ok(_open) = Clipboard::new_attempts(10) else { + return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into())); + }; + if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) { + return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture)); + } + let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?; + Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture)) + } + + /// A clipboard format's bytes, refused past the largest picture a paste takes. + fn read(format: u32) -> Result, String> { + match raw::size(format) { + Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {} + Some(_) => return Err("the clipboard picture is larger than 200 MB".into()), + None => return Err("the clipboard picture is empty".into()), + } + let mut out = Vec::new(); + raw::get_vec(format, &mut out).map_err(|e| e.to_string())?; + Ok(out) } pub fn can_set() -> bool { diff --git a/crates/geom/src/picture.rs b/crates/geom/src/picture.rs index 8ab8578d47..564639f2ae 100644 --- a/crates/geom/src/picture.rs +++ b/crates/geom/src/picture.rs @@ -1,9 +1,11 @@ -//! Decoding a picture within a memory budget. Every crate that decodes a document's pictures goes -//! through [`probe`]: the header gives the format and size, and the decode is refused before -//! anything is allocated when its peak would pass the budget. The peak counts what a decoder holds -//! beside its output (a progressive JPEG's coefficients, #3644), and a picture whose decoder can't -//! be held to a budget is not decoded at all (a TIFF with JPEG strips, #3645). +//! Decoding a picture within a memory budget. Every crate that decodes a document's or the +//! clipboard's pictures goes through [`probe`] (or [`probe_dib`]): the header gives the format and +//! size, and the decode is refused before anything is allocated when its peak would pass the +//! budget. The peak counts what a decoder holds beside its output (a progressive JPEG's +//! coefficients, #3644), and a picture whose decoder can't be held to a budget is not decoded at +//! all (a TIFF with JPEG strips, #3645). +use std::borrow::Cow; use std::io::Cursor; use image::{DynamicImage, ImageDecoder, ImageFormat}; @@ -40,9 +42,11 @@ fn broken(e: image::ImageError) -> Refusal { /// What a picture's header says: its format, its size and the memory decoding it takes. Reading /// it allocates no pixels. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Probe<'a> { - bytes: &'a [u8], + bytes: Cow<'a, [u8]>, + /// A DIB: a BMP without its file header, as the Windows clipboard holds one. + dib: bool, pub format: ImageFormat, pub width: u32, pub height: u32, @@ -70,7 +74,19 @@ pub fn probe(bytes: &[u8]) -> Result, Refusal> { }; let decoder = image::ImageReader::with_format(Cursor::new(bytes), format).into_decoder().map_err(broken)?; let (width, height, peak) = measure(&decoder, held)?; - Ok(Probe { bytes, format, width, height, peak }) + Ok(Probe { bytes: Cow::Borrowed(bytes), dib: false, format, width, height, peak }) +} + +/// Read a DIB's header: a BMP without its 14-byte file header, as the Windows clipboard holds +/// one (`CF_DIB`, `CF_DIBV5`). Its layout is first made the one the decoder reads (see +/// [`dib_layout`]). +pub fn probe_dib(dib: Vec) -> Result, Refusal> { + let dib = dib_layout(dib); + let (width, height, peak) = { + let decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(dib.as_slice())).map_err(broken)?; + measure(&decoder, 0)? + }; + Ok(Probe { bytes: Cow::Owned(dib), dib: true, format: ImageFormat::Bmp, width, height, peak }) } /// A decoder's size and the peak bytes of decoding it, `held` bytes kept beside its output. @@ -105,7 +121,12 @@ impl Probe<'_> { limits.max_image_width = Some(self.width); limits.max_image_height = Some(self.height); limits.max_alloc = Some(budget); - let mut reader = image::ImageReader::with_format(Cursor::new(self.bytes), self.format); + if self.dib { + let mut decoder = image::codecs::bmp::BmpDecoder::new_without_file_header(Cursor::new(&*self.bytes)).map_err(broken)?; + decoder.set_limits(limits).map_err(broken)?; + return DynamicImage::from_decoder(decoder).map_err(broken); + } + let mut reader = image::ImageReader::with_format(Cursor::new(&*self.bytes), self.format); reader.limits(limits); reader.decode().map_err(broken) } @@ -219,6 +240,37 @@ fn tiff_compression(bytes: &[u8]) -> Option { Some(1) } +/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask +/// is set but whose compression says `BI_RGB` is marked `BI_BITFIELDS` (with the usual colour +/// masks when it gives none), so its alpha is kept as Windows programs mean it; arboard makes the +/// same change. Bit fields then need the colour masks after the header too, where Windows puts a +/// copy and the decoder skips them; a DIB without that copy (arboard writes one) gets it. +fn dib_layout(mut dib: Vec) -> Vec { + let u32_at = |d: &[u8], at: usize| d.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let Some(size) = u32_at(&dib, 0).filter(|&s| s >= 108).and_then(|s| usize::try_from(s).ok()) else { return dib }; + let put = |d: &mut Vec, at: usize, v: u32| { + if let Some(b) = d.get_mut(at..at + 4) { + b.copy_from_slice(&v.to_le_bytes()); + } + }; + if dib.get(14..16) == Some(&[32, 0]) && u32_at(&dib, 16) == Some(0) && u32_at(&dib, 52) == Some(0xFF00_0000) { + put(&mut dib, 16, 3); + if (40..52).step_by(4).all(|at| u32_at(&dib, at) == Some(0)) { + put(&mut dib, 40, 0x00FF_0000); + put(&mut dib, 44, 0x0000_FF00); + put(&mut dib, 48, 0x0000_00FF); + } + } + if u32_at(&dib, 16) == Some(3) + && size <= dib.len() + && let Some(masks) = dib.get(40..52).map(<[u8]>::to_vec) + && dib.get(size..size + 12) != Some(masks.as_slice()) + { + dib.splice(size..size, masks); + } + dib +} + #[cfg(test)] mod tests { use super::*; @@ -363,4 +415,65 @@ mod tests { assert_eq!(tiff_compression(&tiff), Some(1)); assert_eq!(decode(&tiff, 1 << 20).unwrap().to_rgb8().get_pixel(4, 2).0, [9, 8, 7]); } + + /// A BITMAPV5HEADER DIB of 32-bit pixels, bottom-up, with `alpha_mask`: `BI_RGB`, or with + /// `bitfields` the usual masks and `BI_BITFIELDS`, their copy after the header when `copy`. + fn dib(w: i32, h: i32, alpha_mask: u32, bitfields: bool, copy: bool, pixels: &[[u8; 4]]) -> Vec { + let mut d = vec![0u8; 124]; + d[0..4].copy_from_slice(&124u32.to_le_bytes()); + d[4..8].copy_from_slice(&w.to_le_bytes()); + d[8..12].copy_from_slice(&h.to_le_bytes()); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[14..16].copy_from_slice(&32u16.to_le_bytes()); + d[52..56].copy_from_slice(&alpha_mask.to_le_bytes()); + if bitfields { + d[16..20].copy_from_slice(&3u32.to_le_bytes()); + for (at, m) in [(40, 0x00FF_0000u32), (44, 0x0000_FF00), (48, 0x0000_00FF)] { + d[at..at + 4].copy_from_slice(&m.to_le_bytes()); + } + if copy { + let masks = d[40..52].to_vec(); + d.extend(masks); + } + } + for p in pixels { + d.extend(p); + } + d + } + + #[test] + fn clipboard_dibs_keep_their_alpha_in_every_layout() { + // BGRA in memory: a half-transparent red pixel and an opaque blue one. + let px = [[0, 0, 255, 128], [255, 0, 0, 255]]; + // `BI_RGB` with an alpha mask (Windows programs); bit fields without the masks' copy + // after the header (arboard), and with it (Windows). + for (bitfields, copy) in [(false, false), (true, false), (true, true)] { + let p = probe_dib(dib(2, 1, 0xFF00_0000, bitfields, copy, &px)).unwrap(); + assert_eq!((p.format, p.width, p.height), (ImageFormat::Bmp, 2, 1)); + let rgba = p.decode(1 << 20).unwrap().to_rgba8(); + assert_eq!(rgba.get_pixel(0, 0).0, [255, 0, 0, 128], "{bitfields} {copy}"); + assert_eq!(rgba.get_pixel(1, 0).0, [0, 0, 255, 255], "{bitfields} {copy}"); + } + // Without an alpha mask, `BI_RGB` pixels are opaque. + let p = probe_dib(dib(2, 1, 0, false, false, &px)).unwrap(); + assert_eq!(p.decode(1 << 20).unwrap().to_rgba8().get_pixel(0, 0).0, [255, 0, 0, 255]); + } + + #[test] + fn huge_or_damaged_clipboard_dibs_are_refused() { + let p = probe_dib(dib(60_000, 60_000, 0xFF00_0000, false, false, &[])).unwrap(); + assert_eq!((p.width, p.height), (60_000, 60_000)); + assert!(!p.fits(crate::MAX_DECODED_BYTES)); + assert!(matches!(p.decode(crate::MAX_DECODED_BYTES), Err(Refusal::TooLarge { .. }))); + assert!(probe_dib(vec![0; 8]).is_err()); + // Cut short or lying about its header: no pixels come out, and nothing panics. + let mut short = dib(2, 1, 0xFF00_0000, true, false, &[]); + short.truncate(100); + let mut lying = dib(2, 1, 0xFF00_0000, true, false, &[[0; 4], [0; 4]]); + lying[0..4].copy_from_slice(&u32::MAX.to_le_bytes()); + for d in [short, lying] { + assert!(probe_dib(d).and_then(|p| p.decode(1 << 20)).is_err()); + } + } } diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index 5f484d7465..ba3a598cf6 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -56,6 +56,47 @@ pub fn png_from_rgba(width: usize, height: usize, rgba: Vec) -> Result bool { + let (w, h) = (width as usize, height as usize); + w <= MAX_SIDE && h <= MAX_SIDE && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS) +} + +fn too_large_to_paste(width: u32, height: u32) -> ClipboardPicture { + ClipboardPicture::Unreadable(format!("the clipboard picture is too large to paste ({width} × {height} pixels)")) +} + +/// An encoded picture the host read from the clipboard (the Windows "PNG" format), refused from +/// its header when a paste can't take its size, before anything decodes it (#3623). +pub fn encoded_picture(bytes: Vec) -> ClipboardPicture { + match wordcraft_geom::picture::probe(&bytes).map(|p| (p.width, p.height)) { + Ok((w, h)) if pastes(w, h) => ClipboardPicture::Encoded(bytes), + Ok((w, h)) => too_large_to_paste(w, h), + Err(e) => ClipboardPicture::Unreadable(e.to_string()), + } +} + +/// A DIB from the Windows clipboard (`CF_DIBV5`, `CF_DIB`) as pixels: its size is checked from +/// its header, then it is decoded within the decode budget. arboard decoded it with no limits +/// before anything was checked (#3623). +pub fn dib_picture(dib: Vec) -> ClipboardPicture { + let p = match wordcraft_geom::picture::probe_dib(dib) { + Ok(p) => p, + Err(e) => return ClipboardPicture::Unreadable(e.to_string()), + }; + if !pastes(p.width, p.height) { + return too_large_to_paste(p.width, p.height); + } + match p.decode(wordcraft_geom::MAX_DECODED_BYTES) { + Ok(img) => { + let rgba = img.into_rgba8(); + ClipboardPicture::Pixels { width: rgba.width() as usize, height: rgba.height() as usize, rgba: rgba.into_raw() } + } + Err(e) => ClipboardPicture::Unreadable(e.to_string()), + } +} + fn is_picture_file(path: &Path) -> bool { path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| IMAGE_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) } @@ -184,6 +225,44 @@ mod tests { assert!(!names_picture_files("see /tmp/b.png") && !names_picture_files("notes.txt") && !names_picture_files("")); } + /// A BITMAPV5HEADER DIB of 32-bit `BI_RGB` pixels (bottom-up) with an alpha mask, as Windows + /// programs put one on the clipboard. + fn dib(w: i32, h: i32, pixels: &[[u8; 4]]) -> Vec { + let mut d = vec![0u8; 124]; + d[0..4].copy_from_slice(&124u32.to_le_bytes()); + d[4..8].copy_from_slice(&w.to_le_bytes()); + d[8..12].copy_from_slice(&h.to_le_bytes()); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[14..16].copy_from_slice(&32u16.to_le_bytes()); + d[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + d.extend(pixels.iter().flatten()); + d + } + + /// #3623: what the Windows clipboard holds is checked from its header before it is decoded. + #[test] + fn windows_clipboard_pictures_are_checked_from_their_headers() { + let mut png = Vec::new(); + image::RgbaImage::new(3, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert!(matches!(encoded_picture(png.clone()), ClipboardPicture::Encoded(b) if b == png)); + let mut wide = Vec::new(); + image::GrayImage::new(20_000, 1).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + assert!(matches!(encoded_picture(wide), ClipboardPicture::Unreadable(e) if e.contains("too large"))); + assert!(matches!(encoded_picture(b"not a picture".to_vec()), ClipboardPicture::Unreadable(_))); + // BGRA in memory: a half-transparent red pixel and an opaque blue one. + let ClipboardPicture::Pixels { width, height, rgba } = dib_picture(dib(2, 1, &[[0, 0, 255, 128], [255, 0, 0, 255]])) else { + panic!("pixels") + }; + assert_eq!((width, height, rgba), (2, 1, vec![255, 0, 0, 128, 0, 0, 255, 255])); + // A header claiming 60000 × 60000 over no pixels is refused unread. + assert!(matches!(dib_picture(dib(60_000, 60_000, &[])), ClipboardPicture::Unreadable(e) if e.contains("too large"))); + assert!(matches!(dib_picture(vec![0; 10]), ClipboardPicture::Unreadable(_))); + // And a DIB pastes as a picture. + let mut a = app_with(|| Some(dib_picture(dib(2, 2, &[[9, 9, 9, 255]; 4])))); + a.run("edit.paste", json!({})).unwrap(); + assert_eq!(pictures(&mut a), 1); + } + /// Hostile clipboard sizes are refused without inserting anything (or allocating for them). #[test] fn hostile_clipboard_pictures_are_refused() {