200 lines
8.8 KiB
Rust
200 lines
8.8 KiB
Rust
//! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote
|
||
//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is
|
||
//! compressed already), files written the old way still open, entries are read whatever their
|
||
//! order, and the limits apply per part of the file.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::io::{Read, Write};
|
||
|
||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||
use vectorcraft_format::{BLOB_MAGIC, FormatError, INLINE_MAX, MAX_BLOB, SaveOptions, load, load_file, pdf_content, preview, save_to, save_with};
|
||
use vectorcraft_geom::Affine;
|
||
|
||
/// A document showing images `(key, mime, len)`.
|
||
fn doc_with(images: &[(&str, &str, usize)]) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let l = d.layers[0].id;
|
||
for (n, &(key, mime, len)) in images.iter().enumerate() {
|
||
let bytes: Vec<u8> = (0..len).map(|i| ((i * 7 + n * 13) % 251) as u8).collect();
|
||
d.images.insert(key.into(), ImageBlob::new(mime, bytes));
|
||
let id = d.alloc_id();
|
||
let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() };
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
|
||
}
|
||
d
|
||
}
|
||
|
||
/// gzip as the app compressed files before saves were streamed (the whole file at once).
|
||
fn gzip(bytes: &[u8]) -> Vec<u8> {
|
||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||
gz.write_all(bytes).unwrap();
|
||
gz.finish().unwrap()
|
||
}
|
||
|
||
fn gunzip(bytes: &[u8]) -> Vec<u8> {
|
||
let mut out = vec![];
|
||
flate2::read::GzDecoder::new(bytes).read_to_end(&mut out).unwrap();
|
||
out
|
||
}
|
||
|
||
fn mixed() -> Document {
|
||
doc_with(&[
|
||
("photo", "image/jpeg", INLINE_MAX * 3),
|
||
("scan", "image/tiff", INLINE_MAX * 2),
|
||
("icon", "image/png", 100),
|
||
("art", "image/png", INLINE_MAX + 1),
|
||
])
|
||
}
|
||
|
||
#[test]
|
||
fn compressed_files_unpack_to_the_plain_file() {
|
||
let pdf = vec![b'%'; INLINE_MAX * 2];
|
||
let profiles = BTreeMap::from([("Mine".to_string(), vec![7u8; INLINE_MAX + 3])]);
|
||
for d in [Document::new(10.0, 10.0), mixed()] {
|
||
let o = SaveOptions { pdf: Some(pdf.clone()), profiles: profiles.clone(), preview: Some(vec![1, 2, 3]), ..SaveOptions::default() };
|
||
let plain = save_with(&d, &o).unwrap();
|
||
let packed = save_with(&d, &SaveOptions { compress: true, ..o }).unwrap();
|
||
assert_eq!(gunzip(&packed), plain, "the same file, compressed");
|
||
let back = load_file(&packed).unwrap();
|
||
for (k, b) in &d.images {
|
||
assert_eq!(back.doc.images[k].bytes, b.bytes, "{k}");
|
||
}
|
||
assert_eq!(back.profiles, profiles);
|
||
assert_eq!(pdf_content(&packed).unwrap(), pdf);
|
||
assert_eq!(preview(&packed).unwrap(), vec![1, 2, 3]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn without_compressed_images_the_gzip_bytes_are_the_old_ones() {
|
||
let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]);
|
||
d.title = "No photos".into();
|
||
for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] {
|
||
let plain = save_with(&d, &o).unwrap();
|
||
assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn files_compressed_the_old_way_still_open() {
|
||
let d = mixed();
|
||
let old = gzip(&save_with(&d, &SaveOptions::default()).unwrap());
|
||
let back = load(&old).unwrap();
|
||
assert_eq!(back.images.len(), d.images.len());
|
||
for (k, b) in &d.images {
|
||
assert_eq!(back.images[k].bytes, b.bytes, "{k}");
|
||
}
|
||
assert_eq!(back.node_count(), d.node_count());
|
||
}
|
||
|
||
#[test]
|
||
fn save_to_writes_what_save_with_returns() {
|
||
let d = mixed();
|
||
for compress in [false, true] {
|
||
let o = SaveOptions { compress, ..SaveOptions::default() };
|
||
let mut streamed = vec![];
|
||
save_to(&d, &o, &mut streamed).unwrap();
|
||
assert_eq!(streamed, save_with(&d, &o).unwrap());
|
||
let back = load(&streamed).unwrap();
|
||
assert_eq!(back.images["photo"].bytes, d.images["photo"].bytes);
|
||
}
|
||
// A failing writer fails the save.
|
||
struct Full;
|
||
impl Write for Full {
|
||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||
Err(std::io::Error::other("disk full"))
|
||
}
|
||
fn flush(&mut self) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
}
|
||
assert!(save_to(&d, &SaveOptions::default(), Full).is_err());
|
||
assert!(save_to(&d, &SaveOptions { compress: true, ..SaveOptions::default() }, Full).is_err());
|
||
}
|
||
|
||
/// `plain` with its JSON changed by `edit` (the data after it kept).
|
||
fn with_json(plain: &[u8], edit: impl FnOnce(&mut serde_json::Value)) -> Vec<u8> {
|
||
let at = plain.windows(BLOB_MAGIC.len()).position(|w| w == BLOB_MAGIC).unwrap();
|
||
let mut v: serde_json::Value = serde_json::from_slice(&plain[..at]).unwrap();
|
||
edit(&mut v);
|
||
let mut out = serde_json::to_vec(&v).unwrap();
|
||
out.extend_from_slice(&plain[at..]);
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn entries_in_any_order_or_shared_read_back() {
|
||
let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX + 5)]);
|
||
let plain = save_with(&d, &SaveOptions::default()).unwrap();
|
||
// Swap the entries' places (b's bytes listed first), and point a third image at a's bytes.
|
||
let file = with_json(&plain, |v| {
|
||
let (a, b) = (v["images"]["a"]["blob"].clone(), v["images"]["b"]["blob"].clone());
|
||
v["images"]["a"]["blob"] = b.clone();
|
||
v["images"]["b"]["blob"] = a.clone();
|
||
v["images"]["c"] = serde_json::json!({"mime": "image/png", "blob": b});
|
||
});
|
||
for bytes in [file.clone(), gzip(&file)] {
|
||
let back = load(&bytes).unwrap();
|
||
assert_eq!(back.images["a"].bytes, d.images["b"].bytes);
|
||
assert_eq!(back.images["b"].bytes, d.images["a"].bytes);
|
||
assert_eq!(back.images["c"].bytes, d.images["b"].bytes);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn limits_apply_per_entry_and_to_all_entries() {
|
||
let d = doc_with(&[("a", "image/png", INLINE_MAX), ("b", "image/png", INLINE_MAX)]);
|
||
let plain = save_with(&d, &SaveOptions::default()).unwrap();
|
||
// One entry past the limit: refused before anything is allocated.
|
||
let huge = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, MAX_BLOB + 1]));
|
||
assert!(matches!(load_file(&gzip(&huge)), Err(FormatError::TooBig)));
|
||
// Each within it, together past the limit for all.
|
||
let many = with_json(&plain, |v| {
|
||
for i in 0..5 {
|
||
v["images"][format!("x{i}")] = serde_json::json!({"mime": "image/png", "blob": [0, MAX_BLOB]});
|
||
}
|
||
});
|
||
assert!(matches!(load_file(&gzip(&many)), Err(FormatError::TooBig)));
|
||
// A length the data doesn't have: the image is damaged, nothing more.
|
||
let short = with_json(&plain, |v| v["images"]["a"]["blob"] = serde_json::json!([0, 1u64 << 20]));
|
||
assert!(matches!(load_file(&gzip(&short)), Err(FormatError::BadImage(_))));
|
||
// Uncompressed files have no limits to apply (the file is all there is).
|
||
assert!(matches!(load_file(&huge), Err(FormatError::BadImage(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn a_damaged_compressed_file_is_refused() {
|
||
let bytes = save_with(&mixed(), &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap();
|
||
// The data after the JSON cut short, then its checksum wrong.
|
||
assert!(load_file(&bytes[..bytes.len() - 4000]).is_err());
|
||
let mut bad = bytes.clone();
|
||
let n = bad.len();
|
||
bad[n - 6] ^= 0xff;
|
||
assert!(load_file(&bad).is_err(), "the checksum is checked to the end");
|
||
}
|
||
|
||
#[test]
|
||
fn documents_read_whatever_their_key_order_and_keep_keys_they_dont_know() {
|
||
let d = mixed();
|
||
let plain = save_with(&d, &SaveOptions::default()).unwrap();
|
||
let file = with_json(&plain, |v| {
|
||
// serde_json's map keeps keys sorted: `type` is no longer first in the node kinds (a
|
||
// layer's `children`, `clip` and `color` come before it), and the document's fields come
|
||
// in another order.
|
||
v["document"]["aaa_from_a_newer_version"] = serde_json::json!({"x": [1, 2]});
|
||
v["document"]["zzz_too"] = serde_json::json!("kept");
|
||
});
|
||
let text = String::from_utf8_lossy(&file);
|
||
assert!(!text.contains(r#"{"type":"layer""#) && text.contains(r#""type":"layer""#), "`type` isn't first");
|
||
for bytes in [file.clone(), gzip(&file)] {
|
||
let back = load(&bytes).unwrap();
|
||
assert_eq!(back.layers, load(&plain).unwrap().layers);
|
||
assert_eq!(back.extra["aaa_from_a_newer_version"]["x"], serde_json::json!([1, 2]));
|
||
assert_eq!(back.extra["zzz_too"], "kept");
|
||
// Saved again, they are kept.
|
||
assert_eq!(load(&save_with(&back, &SaveOptions::default()).unwrap()).unwrap().extra["zzz_too"], "kept");
|
||
}
|
||
}
|