mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 11:31:43 +00:00
- The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the cap the .fig, .sketch and REST importers already read images with (now one constant), instead of 32 MiB: an imported image larger than 32 MiB made the document unjoinable and its server snapshot unrestorable. - History written before the checks (a restored or joined snapshot, a replayed server log, a file published to a server) applies without the image entries they refuse (docprops::without_refused_images), instead of the file not opening or a restart dropping every edit after the image. - A key that already holds an image can't be given other bytes: producers key images by their bytes (the REST importer's placeholder keys always hold the same picture), and merge treats a key's image as never changing. - REST import: an unresolved imageRef shaped like a content hash shares the rest-image-missing placeholder (the placeholder's bytes don't hash to it); ImageData::claims_content_hash is shared with collab. - Tests: collab image_tests (4 new, size test uses a small cap), format image_tests (1 new). - Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md.
163 lines
8.2 KiB
Rust
163 lines
8.2 KiB
Rust
//! Images a native file can't give back: damaged or missing members are reported, not dropped
|
|
//! without a word.
|
|
|
|
use std::io::Write;
|
|
|
|
use figcraft_doc::{Document, NodeKind};
|
|
|
|
use super::*;
|
|
|
|
fn png() -> Vec<u8> {
|
|
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([9, 0, 0, 255]));
|
|
let mut out = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
|
out
|
|
}
|
|
|
|
fn doc_with_image() -> (Document, String) {
|
|
let mut d = Document::new("File");
|
|
let p = d.first_page().unwrap();
|
|
d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
|
let (hash, ..) = add_image(&mut d, png()).unwrap();
|
|
(d, hash)
|
|
}
|
|
|
|
/// The archive of `d` rebuilt with `edit` applied to each member (None drops it).
|
|
fn rebuild(bytes: &[u8], edit: impl Fn(&str, Vec<u8>) -> Option<Vec<u8>>) -> Vec<u8> {
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
|
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
let mut w = zip::ZipWriter::new(&mut buf);
|
|
for n in names {
|
|
let data = read_member(&mut z, &n, u64::MAX).unwrap();
|
|
if let Some(data) = edit(&n, data) {
|
|
w.start_file(n, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap();
|
|
w.write_all(&data).unwrap();
|
|
}
|
|
}
|
|
w.finish().unwrap();
|
|
buf.into_inner()
|
|
}
|
|
|
|
#[test]
|
|
fn an_intact_file_loads_without_image_issues() {
|
|
let (d, hash) = doc_with_image();
|
|
let (loaded, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
|
|
assert!(issues.is_empty(), "{issues:?}");
|
|
assert!(loaded.images.contains_key(&hash));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_image_member_is_reported() {
|
|
let (d, hash) = doc_with_image();
|
|
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
|
|
let (loaded, issues) = load_bytes(&bytes).unwrap();
|
|
assert!(!loaded.images.contains_key(&hash));
|
|
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("missing") && i.contains("drops it")), "{issues:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_image_member_is_reported_with_its_reason() {
|
|
let (d, hash) = doc_with_image();
|
|
let bytes = save_bytes(&d, None).unwrap();
|
|
// The member's bytes change but not its checksum: reading it fails.
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
|
let name = format!("images/{hash}");
|
|
let stored = read_member(&mut z, &name, u64::MAX).unwrap();
|
|
let at = bytes.windows(stored.len()).position(|w| w == stored).unwrap();
|
|
let mut damaged = bytes.clone();
|
|
damaged[at + stored.len() / 2] ^= 0xff;
|
|
let (loaded, issues) = load_bytes(&damaged).unwrap();
|
|
assert!(!loaded.images.contains_key(&hash));
|
|
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("couldn't be read") && i.contains("drops it")), "{issues:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn many_lost_images_are_counted_not_listed_one_by_one() {
|
|
let mut d = Document::new("File");
|
|
for k in 0..40u8 {
|
|
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 1, 0, 255]));
|
|
let mut out = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
|
add_image(&mut d, out).unwrap();
|
|
}
|
|
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
|
|
let (_, issues) = load_bytes(&bytes).unwrap();
|
|
assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}");
|
|
assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded"));
|
|
}
|
|
|
|
#[test]
|
|
fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
|
|
let (d, hash) = doc_with_image();
|
|
let bytes = save_bytes(&d, None).unwrap();
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
|
let doc_json = read_member(&mut z, "document.json", u64::MAX).unwrap();
|
|
let mut buf = std::io::Cursor::new(Vec::new());
|
|
let mut w = zip::ZipWriter::new(&mut buf);
|
|
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
w.start_file("document.json", opts).unwrap();
|
|
w.write_all(&doc_json).unwrap();
|
|
for n in ["images/../../x", "images/a/b"] {
|
|
w.start_file(n, opts).unwrap();
|
|
w.write_all(&png()).unwrap();
|
|
}
|
|
w.finish().unwrap();
|
|
let hostile = buf.into_inner();
|
|
let loaded = load_bytes_with_versions(&hostile).unwrap();
|
|
assert!(loaded.doc.images.keys().all(|k| k != "../../x" && k != "a/b"), "{:?}", loaded.doc.images.keys());
|
|
assert!(!loaded.issues.is_empty());
|
|
let saved = save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap();
|
|
let z = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap();
|
|
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
|
|
let _ = hash;
|
|
}
|
|
|
|
/// An unresolved `imageRef` from a hostile REST file never becomes the image's own key: the
|
|
/// save writer names archive members after keys, so `../../evil` would inject an
|
|
/// `images/../../evil` member into the next save. Such refs store the shared placeholder
|
|
/// under its own key instead.
|
|
#[test]
|
|
fn a_path_like_rest_image_ref_never_names_an_archive_member() {
|
|
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
|
|
"fills": [{"type": "IMAGE", "imageRef": "../../evil", "scaleMode": "FIT"}]});
|
|
let file = json!({"name": "Hostile", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
|
|
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
|
|
let (doc, _warnings) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
|
assert!(!doc.images.contains_key("../../evil"), "the raw imageRef must not name an image: {:?}", doc.images.keys().collect::<Vec<_>>());
|
|
assert!(doc.images.contains_key("rest-image-missing"), "the shared placeholder stores it instead: {:?}", doc.images.keys().collect::<Vec<_>>());
|
|
let bytes = save_bytes(&doc, None).unwrap();
|
|
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
|
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
|
|
assert!(
|
|
z.file_names().any(|n| n == "images/rest-image-missing"),
|
|
"the placeholder is saved under its own name: {:?}",
|
|
z.file_names().collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
/// A key with `-` or `_` is a plain archive member name too: the REST importer's placeholder
|
|
/// (`rest-image-missing`) comes back after a save and load instead of being dropped as damaged.
|
|
#[test]
|
|
fn an_image_keyed_with_dashes_comes_back_after_a_save() {
|
|
let mut d = Document::new("t");
|
|
d.images.insert("rest-image-missing".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(png()), width: 2, height: 2 });
|
|
let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap();
|
|
assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues);
|
|
}
|
|
|
|
/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash
|
|
/// to it, which they don't: a live session would refuse that image. It shares the placeholder
|
|
/// instead.
|
|
#[test]
|
|
fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() {
|
|
for image_ref in ["ab".repeat(32), "A".repeat(64)] {
|
|
let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10},
|
|
"fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]});
|
|
let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT",
|
|
"children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}});
|
|
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
|
assert_eq!(doc.images.keys().collect::<Vec<_>>(), ["rest-image-missing"], "{image_ref}");
|
|
}
|
|
}
|