M7: #1360 review: cap the clipboard PNG's bytes before copying it

The pixel cap reads only the PNG's header, so a small image followed by a
large tail of data passed it and was copied and base64-encoded whole.
MAX_CLIPBOARD_PNG_BYTES (256 MB, the same cap as design data read from the
clipboard) is checked on the NSData length before the copy on macOS, and
again in the paste path so every Services implementation is held to it.
A PNG past the cap is treated like an unreadable one: paste falls back to
the decoded image.
This commit is contained in:
Denis Gomes
2026-10-28 14:39:35 -04:00
parent d94609d63f
commit 57dcff6d9a
5 changed files with 23 additions and 3 deletions
+1 -1
View File
@@ -445,7 +445,7 @@ impl Services for Desktop {
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
Some(png.to_vec())
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
}
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
+4 -1
View File
@@ -284,7 +284,10 @@ mod state;
pub(crate) use clock::repaint_in;
pub use clock::{AUTO_SAVE_SECS, AUTOSAVE_SECS, FrameClock, ToastClock};
pub(crate) use open::{drop_overlay, dropped_bytes, is_document, opens};
pub use services::{ContentsFuture, DialogFuture, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text, unreadable};
pub use services::{
ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text,
unreadable,
};
use services::{DialogDone, PickFuture, Picked, RepaintWaker};
pub use state::{
AutoSaveEncoding, CANVAS_MIN, LEFT_PANEL, LeftTab, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels,
+2 -1
View File
@@ -101,7 +101,8 @@ fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool)
/// The system clipboard's image as PNG bytes, with its size.
fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
let (png, (w, h)) = match app.services.clipboard_get_png().and_then(with_pixel_size) {
let clipboard_png = app.services.clipboard_get_png().filter(|png| png.len() <= crate::MAX_CLIPBOARD_PNG_BYTES);
let (png, (w, h)) = match clipboard_png.and_then(with_pixel_size) {
Some(readable) => readable,
None => with_pixel_size(encoded_clipboard_image(app)?)?,
};
+14
View File
@@ -534,3 +534,17 @@ fn an_unreadable_png_on_the_clipboard_falls_back_to_the_decoded_image() {
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
#[test]
fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() {
let mut w = Win::new();
let mut png = std::io::Cursor::new(Vec::new());
image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
let mut png = png.into_inner();
png.resize(crate::MAX_CLIPBOARD_PNG_BYTES + 1, 0);
*w.clip.borrow_mut() = Clip { png: Some(png), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
w.cmd_v(CMD);
let doc = w.app.session.doc().unwrap();
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
}
+2
View File
@@ -1,5 +1,7 @@
use super::*;
pub const MAX_CLIPBOARD_PNG_BYTES: usize = 256 * 1024 * 1024;
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
pub trait Services {
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {