diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27d9652e..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,11 +26,11 @@ pub fn specs() -> Vec { spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), spec!( - "edit.pasteFromFigma", - "Paste from Figma", - "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", true, - paste_from_figma + paste_design_layers ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), @@ -544,35 +544,42 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied { (nodes, images, bounds) } -const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; -/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. -pub fn figma_clipboard_buffer(html: &str) -> Option> { +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { use base64::Engine as _; - const OPEN: &str = "(figma)"; - let start = html.find(OPEN)? + OPEN.len(); - let len = html.get(start..)?.find("(/figma)")?; - let text = html.get(start..start + len)?; - if text.len() > MAX_FIGMA_CLIP { + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { return None; } let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - let bytes = - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; figcraft_format::fig::is_fig(&bytes).then_some(bytes) } -fn paste_from_figma(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteFromFigma"; +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, - (None, Some(d)) => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? - } + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, (None, None) => return Err(bad(CMD, "pass `html` or `data`")), }; - let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; let doc = std::sync::Arc::new(other.doc); let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); if tops.is_empty() { diff --git a/crates/engine/src/figma_paste_tests.rs b/crates/engine/src/design_paste_tests.rs similarity index 53% rename from crates/engine/src/figma_paste_tests.rs rename to crates/engine/src/design_paste_tests.rs index 7468f3ac..89de80ef 100644 --- a/crates/engine/src/figma_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -1,8 +1,11 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + use base64::Engine as _; use serde_json::json; use super::*; -use crate::cmd::edit::figma_clipboard_buffer; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; @@ -20,12 +23,9 @@ fn copied_file() -> Vec { } fn clipboard_html(buffer: &[u8]) -> String { - let meta = B64.encode(br#"{"dataType":"scene"}"#); let mut wrapped = B64.encode(buffer); wrapped.insert(8, '\n'); - format!( - r#""# - ) + format!(r#""#) } fn names(s: &Session, ids: &[NodeId]) -> Vec { @@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec { #[test] fn buffer_is_found_in_the_html() { let file = copied_file(); - assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); - assert_eq!(figma_clipboard_buffer("

plain

"), None); - assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); - assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); - assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); } #[test] fn layers_paste_into_the_page_and_undo() { let mut s = Session::new(); let page = s.page().unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); assert_eq!(s.selection(), ids); @@ -61,9 +74,12 @@ fn layers_paste_into_the_page_and_undo() { } #[test] -fn a_second_paste_repeats_the_figma_layers() { +fn a_second_paste_repeats_the_copied_layers() { let mut s = Session::new(); - s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); let r = s.execute("edit.paste", &json!({})).unwrap(); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); assert_eq!(names(&s, &ids), ["Card", "Dot"]); @@ -74,7 +90,7 @@ fn pastes_into_the_selected_frame() { let mut s = Session::new(); let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); s.execute("select.set", &json!({"ids": [host]})).unwrap(); - let r = s.execute("edit.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); let first = params::id_of(&r["ids"][0]).unwrap(); assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); } @@ -83,10 +99,10 @@ fn pastes_into_the_selected_frame() { fn other_content_is_refused_without_changes() { let mut s = Session::new(); let before = s.doc().unwrap().nodes().count(); - assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); assert_eq!(s.doc().unwrap().nodes().count(), before); assert!(s.clipboard.nodes.is_empty()); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 908c952a..b124c1e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1500,6 +1500,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1528,8 +1530,6 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs similarity index 100% rename from crates/ui-egui/src/figma_paste_tests.rs rename to crates/ui-egui/src/design_paste_tests.rs