#647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64

This commit is contained in:
bflatastic
2026-10-17 14:34:57 +07:00
parent 946006f100
commit 3353d2d5d3
4 changed files with 65 additions and 42 deletions
+29 -22
View File
@@ -26,11 +26,11 @@ pub fn specs() -> Vec<CommandSpec> {
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)<base64>(/figma)`. This gives those bytes.
pub fn figma_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`].
fn clip_base64(text: &str) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Value> {
const CMD: &str = "edit.pasteFromFigma";
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect();
if tops.is_empty() {
@@ -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<u8> {
}
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#"<meta charset="utf-8"><span data-metadata="<!--(figmeta){meta}(/figmeta)-->"></span><span data-buffer="<!--(figma){wrapped}(/figma)-->"></span>"#
)
format!(r#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
}
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
@@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
#[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("<p>plain</p>"), 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("<p>plain</p>"), 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!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), 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<NodeId> = 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<NodeId> = 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": "<b>hi</b>"})).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": "<b>hi</b>"})).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());
}
+2 -2
View File
@@ -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;