fix(engine): preserve copied smart object source records

Carry only the linked-source records referenced by copied layers through Copy/Paste and save/reopen. Preserve raw records, avoid duplicate payloads on same-document paste, and reject ambiguous or incomplete selected identities before replacing the clipboard.

Co-authored-by: skylar taylor-barrick <[email protected]>
This commit is contained in:
Scott C Hughesandskylar taylor-barrick authored and GitHub committed 2026-10-12 23:38:45 -06:00
1 parent cda46050f7
commit 9ccd4aa557
3 files changed
+644 -1

No files matched your search

+91 -1
View File
@@ -55,6 +55,46 @@ fn scratch_doc(doc: &Document) -> Document {
copies
}
/// Scope linked records to the copied layer tree: a PSD block can also hold files belonging
/// to unselected or hidden layers, which must not follow this copy into a native save.
fn copy_linked_sources(source: &Document, copies: &mut Document) -> Result<()> {
fn collect<'a>(layers: &'a [Layer], depth: usize, paths: &mut std::collections::HashSet<&'a str>) -> Result<()> {
if depth > photocraft_doc::MAX_GROUP_DEPTH {
return Err(EngineError::Other("can't copy linked smart objects from layers nested too deeply".into()));
}
for layer in layers {
if let LayerContent::Smart(sm) = &layer.content
&& let photocraft_doc::SmartSource::Linked { path } = &sm.source
{
paths.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source IDs".into()))?;
paths.insert(path);
}
if let Some(children) = layer.children().filter(|children| !children.is_empty()) {
collect(children, depth + 1, paths)?;
}
}
Ok(())
}
let mut paths = std::collections::HashSet::new();
collect(&copies.layers, 0, &mut paths)?;
if paths.is_empty() {
return Ok(());
}
for (signature, key, data) in &source.metadata.psd_global_blocks {
if !matches!(key, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE") {
continue;
}
let selected = photocraft_io::linked::select_items(data, &|uuid, _| paths.contains(uuid))
.map_err(|e| EngineError::Other(format!("Could not copy smart-object contents: {e}; the clipboard was not changed")))?;
let selected = selected.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
if !selected.is_empty() {
copies.metadata.psd_global_blocks.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to copy smart-object source records".into()))?;
copies.metadata.psd_global_blocks.push((*signature, *key, selected));
}
}
Ok(())
}
/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour
/// holding it.
fn whole_layer(d: &crate::DocState, id: LayerId) -> Option<Box<Document>> {
@@ -206,7 +246,10 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
} else {
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
let several = multi_copy_ids(d);
let layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) };
let mut layers = if several.is_empty() { whole_layer(d, id) } else { whole_layers(d, &several) };
if let Some(copies) = &mut layers {
copy_linked_sources(&d.doc, copies)?;
}
match (d.doc.layer(id).and_then(Layer::surface).filter(|_| several.is_empty()), &layers) {
(Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? },
// A group has no pixels of its own: the clipboard pixels are what it shows.
@@ -345,8 +388,27 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
// A copied smart object may name a PSD-embedded linked file; land its `lnk` block too (#3584).
let mut linked = Vec::new();
for (signature, key, data) in copies.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")) {
if d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && std::sync::Arc::ptr_eq(dd, data)) {
continue;
}
// The clipboard may contain a subset of a destination block. Compare complete records,
// so copying one layer in the same document does not duplicate large embedded files.
let missing = photocraft_io::linked::select_items(data, &|_, raw| {
!d.doc.metadata.psd_global_blocks.iter().any(|(_, dk, dd)| dk == key && photocraft_io::linked::contains_item(dd, raw))
})
.map_err(|e| EngineError::Other(format!("Could not paste smart-object contents: {e}; the document was not changed")))?;
let missing = missing.map(std::sync::Arc::new).unwrap_or_else(|| data.clone());
if !missing.is_empty() {
linked.try_reserve(1).map_err(|_| EngineError::Other("not enough memory to paste smart-object source records".into()))?;
linked.push((*signature, *key, missing));
}
}
let id = s.edit("Paste", |doc, active| {
doc.patterns.extend(patterns);
doc.metadata.psd_global_blocks.extend(linked);
for l in copies.layers {
*active = Some(doc.insert_above(*active, l));
}
@@ -1656,6 +1718,34 @@ mod tests {
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place");
}
/// #3584: a PSD-embedded linked smart object (its bytes in the document's `lnk2` block, its
/// source a uuid) can still be opened after Copy + Paste into another document.
#[test]
fn paste_keeps_a_psd_embedded_smart_object_openable() {
use std::sync::Arc;
let mut s = session();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let outer = s.active().unwrap().active_layer.unwrap();
s.edit("embedded linked source", |doc, _| {
let LayerContent::Smart(sm) = &mut doc.layer_mut(outer).ok_or(EngineError::NoDocument)?.content else {
return Err(EngineError::Other("not a smart object".into()));
};
sm.source = photocraft_doc::SmartSource::Linked { path: "uuid-1".into() };
let linked = photocraft_io::linked::LinkedFile { uuid: "uuid-1".into(), file_name: "in.png".into(), bytes: vec![1, 2, 3, 4] };
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(photocraft_io::linked::encode_linked_file(&linked))));
Ok(())
})
.unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") };
assert!(
crate::smart_cmds::source_bytes(&s.active().unwrap().doc.metadata, &sm.source).is_some(),
"the pasted PSD-embedded linked source must resolve (#3584)"
);
}
/// Type and shape layers copy their rendered pixels too; groups and adjustments have none.
#[test]
fn copy_reads_any_layer_that_shows_pixels() {
@@ -0,0 +1,399 @@
//! Clipboard copies carry only the linked smart-object records their layers reference.
use photocraft_doc::{Document, LayerContent, LayerId, SmartSource};
use photocraft_engine::{Session, smart_cmds::source_bytes};
use photocraft_geom::Rect;
use serde_json::json;
use std::sync::Arc;
fn fixture(depth: u64) -> Document {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("synthetic color", |doc, active| {
doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(0, 0, 8, 6), &[1.0, 0.0, 0.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let bytes = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes;
let doc = photocraft_io::import("synthetic.psd", &bytes).unwrap().document;
assert!(doc.walk().iter().any(|(_, _, l)| matches!(l.content, LayerContent::Smart(_))));
doc
}
fn smart_id(doc: &Document) -> LayerId {
doc.walk().into_iter().find_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).unwrap()
}
fn bytes(doc: &Document, id: LayerId) -> Arc<Vec<u8>> {
let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("expected smart object") };
source_bytes(&doc.metadata, &sm.source).unwrap().1
}
fn hash(bytes: &[u8]) -> (usize, String) {
(bytes.len(), blake3::hash(bytes).to_hex().to_string())
}
fn source_session(doc: Document) -> (Session, LayerId) {
let mut s = Session::new();
let id = smart_id(&doc);
s.add_document(doc, None);
s.select_layer(id).unwrap();
(s, id)
}
fn roundtrip(doc: &Document, format: &str) -> Document {
if format == "pcraft" {
photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(doc, &Default::default()).unwrap()).unwrap()
} else {
let out = photocraft_io::export(doc, format, &Default::default()).unwrap();
assert!(out.warnings.is_empty(), "{format}: {:?}", out.warnings);
photocraft_io::import(&format!("clipboard.{format}"), &out.bytes).unwrap().document
}
}
#[test]
fn clipboard_valid_psd_smart_sources_open_reopen_and_undo() {
for depth in [8, 16, 32] {
for key in [*b"lnk2", *b"lnk3", *b"lnkD"] {
let mut doc = fixture(depth);
for (_, k, _) in &mut doc.metadata.psd_global_blocks {
if matches!(&*k, b"lnk2" | b"lnk3" | b"lnkD") {
*k = key;
}
}
let (mut s, original) = source_session(doc);
let expected = bytes(&s.active().unwrap().doc, original);
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
let before = s.active().unwrap().doc.clone();
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "depth {depth}, key {key:?}");
let after = s.active().unwrap().doc.clone();
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
s.set_active(1);
for format in ["pcraft", "psd", "psb"] {
let back = roundtrip(&s.active().unwrap().doc, format);
let (mut reopened, id) = source_session(back);
reopened.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0], "depth {depth}, {format}");
}
}
}
}
#[test]
fn clipboard_batch_and_same_document_duplicates_keep_sources() {
for depth in [8, 16, 32] {
let (mut s, original) = source_session(fixture(depth));
let duplicate = s.execute("layer.duplicate", json!({"layer": original.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layer": original.0})).unwrap();
s.execute("layer.select", json!({"layer": duplicate, "mode": "add"})).unwrap();
let expected = bytes(&s.active().unwrap().doc, original);
let count = s.active().unwrap().doc.metadata.psd_global_blocks.len();
s.execute("edit.copy", json!({})).unwrap();
let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap();
assert!(!copied.metadata.psd_global_blocks.is_empty());
assert!(
copied.metadata.psd_global_blocks.iter().all(|(_, key, data)| s
.active()
.unwrap()
.doc
.metadata
.psd_global_blocks
.iter()
.any(|(_, dk, dd)| { dk == key && Arc::ptr_eq(dd, data) })),
"an exclusive linked block should stay shared, including large PSBs"
);
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.metadata.psd_global_blocks.len(), count, "same-document paste shouldn't duplicate blocks");
for (_, _, l) in s.active().unwrap().doc.walk() {
if matches!(l.content, LayerContent::Smart(_)) {
assert_eq!(hash(&bytes(&s.active().unwrap().doc, l.id)), hash(&expected));
}
}
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let ids: Vec<_> =
s.active().unwrap().doc.walk().into_iter().filter_map(|(_, _, l)| matches!(l.content, LayerContent::Smart(_)).then_some(l.id)).collect();
assert_eq!(ids.len(), 2);
for id in ids {
assert_eq!(hash(&bytes(&s.active().unwrap().doc, id)), hash(&expected));
s.execute("layer.smartObjects.editContents", json!({"layer": id.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
s.set_active(1);
}
}
}
#[test]
fn clipboard_converts_parent_without_losing_contents_and_disk_reopen() {
for mode in ["rgb", "grayscale", "cmyk", "lab"] {
for depth in [8, 16, 32] {
let (mut s, id) = source_session(fixture(16));
let expected = bytes(&s.active().unwrap().doc, id);
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 6, "depth": depth, "mode": mode})).unwrap();
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected), "{mode}, depth {depth}");
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
s.set_active(1);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pasted.pcraft");
s.execute("file.saveACopy", json!({"path": path})).unwrap();
let mut reopened = Session::new();
reopened.execute("file.openAs", json!({"path": path})).unwrap();
let reopened_id = smart_id(&reopened.active().unwrap().doc);
assert_eq!(hash(&bytes(&reopened.active().unwrap().doc, reopened_id)), hash(&expected));
reopened.execute("layer.smartObjects.editContents", json!({"layer": reopened_id.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&reopened.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
}
}
}
#[test]
fn clipboard_group_keeps_nested_linked_sources() {
let mut doc = fixture(16);
let id = smart_id(&doc);
let expected = bytes(&doc, id);
let child = doc.remove(id).unwrap();
let group = photocraft_doc::Layer::group("synthetic group", vec![child]);
let group_id = group.id;
doc.layers.push(group);
let (mut s, _) = source_session(doc);
s.select_layer(group_id).unwrap();
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 6, "depth": 16})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let pasted = smart_id(&s.active().unwrap().doc);
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected));
s.execute("layer.smartObjects.editContents", json!({"layer": pasted.0})).unwrap();
assert_eq!(photocraft_compose::flatten(&s.active().unwrap().doc).px[0], [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn copy_excludes_unselected_linked_records_from_native_save() {
let mut doc = fixture(8);
let original = smart_id(&doc);
let expected = bytes(&doc, original);
let mut unselected = doc.layer(original).unwrap().duplicate();
unselected.name = "Unselected private synthetic document".into();
unselected.visible = false;
let LayerContent::Smart(sm) = &mut unselected.content else { panic!("smart") };
sm.source = SmartSource::Linked { path: "unselected-private-source".into() };
doc.layers.push(unselected);
let mut private_child = photocraft_engine::smart_cmds::decode_source("source.psb", &expected).unwrap();
for layer in &mut private_child.layers {
if let Some(surface) = layer.surface_mut() {
surface.fill_rect(Rect::new(0, 0, 8, 6), &[0.0, 0.0, 1.0, 1.0]);
}
}
let private_bytes = photocraft_io::export(&private_child, "psb", &Default::default()).unwrap().bytes;
assert_ne!(hash(&expected), hash(&private_bytes));
let private_source =
photocraft_io::linked::LinkedFile { uuid: "unselected-private-source".into(), file_name: "private-synthetic.psb".into(), bytes: private_bytes.clone() };
// Files may put both selected and unselected entries in one global linked-layer block.
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
let selected_record = block.clone();
let mut combined = block.to_vec();
combined.extend(photocraft_io::linked::encode_linked_file(&private_source));
combined.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]); // unrelated unreadable tail
*block = Arc::new(combined);
let (mut s, _) = source_session(doc);
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
let retained = photocraft_io::linked::find_linked_file(&back.metadata, "unselected-private-source");
if let Some(file) = &retained {
assert_eq!(hash(&file.bytes), hash(&private_bytes));
}
assert!(retained.is_none(), "copying one selected layer must not retain another unselected smart object's embedded file in saved destination .pcraft");
let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect();
assert_eq!(linked.len(), 1);
assert_eq!(hash(&linked[0].2), hash(&selected_record), "unidentified tail bytes must not follow the selected record");
}
#[test]
fn copy_keeps_selected_external_alias_and_unknown_records_without_reading_their_source() {
for key in [*b"lnk2", *b"lnk3", *b"lnkD", *b"lnkE"] {
for kind in [*b"liFE", *b"liFA", *b"next"] {
let mut doc = fixture(8);
let id = smart_id(&doc);
let LayerContent::Smart(sm) = &doc.layer(id).unwrap().content else { panic!("smart") };
let source = sm.source.clone();
let (_, _, raw) = doc.metadata.psd_global_blocks.iter().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
let mut selected = raw.to_vec();
selected[8..12].copy_from_slice(&kind);
selected[12..16].copy_from_slice(&42u32.to_be_bytes());
let mut data = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
uuid: "unselected-external".into(),
file_name: "unselected.psb".into(),
bytes: vec![9; 8],
});
data.extend(&selected);
doc.metadata.psd_global_blocks.retain(|(_, k, _)| !matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE"));
doc.metadata.psd_global_blocks.push((*b"8BIM", key, Arc::new(data)));
let (mut s, _) = source_session(doc);
s.execute("edit.copy", json!({})).unwrap();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
let LayerContent::Smart(sm) = &s.active().unwrap().doc.layer(pasted).unwrap().content else { panic!("smart") };
assert_eq!(sm.source, source, "source identity is retained; missing external contents aren't read during Copy");
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
let linked: Vec<_> = back.metadata.psd_global_blocks.iter().filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD" | b"lnkE")).collect();
assert_eq!(linked.len(), 1);
assert_eq!(linked[0].1, key);
assert_eq!(hash(&linked[0].2), hash(&selected));
}
}
}
#[test]
fn copy_refuses_a_truncated_selected_record_without_changing_document_or_clipboard() {
let (mut s, _) = source_session(fixture(8));
s.execute("edit.copy", json!({})).unwrap();
let before_clip = s.clipboard.clone().unwrap();
s.edit("truncate selected linked record", |doc, _| {
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
let mut truncated = block.to_vec();
truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes());
*block = Arc::new(truncated);
Ok(())
})
.unwrap();
let before_doc = s.active().unwrap().doc.clone();
let error = s.execute("edit.copy", json!({})).unwrap_err();
assert!(error.to_string().contains("truncated selected"), "{error}");
assert_eq!(s.active().unwrap().doc, before_doc);
let after_clip = s.clipboard.as_ref().unwrap();
assert_eq!(after_clip.bounds, before_clip.bounds);
assert_eq!(after_clip.layers, before_clip.layers);
}
#[test]
fn repeated_same_document_subset_pastes_do_not_duplicate_embedded_files() {
let mut doc = fixture(16);
let original = smart_id(&doc);
let expected = bytes(&doc, original);
let (_, _, block) = doc.metadata.psd_global_blocks.iter_mut().find(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")).unwrap();
let selected_record = block.clone();
let mut combined = block.to_vec();
combined.extend(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
uuid: "unselected".into(),
file_name: "another.psb".into(),
bytes: expected.to_vec(),
}));
*block = Arc::new(combined);
let (mut s, _) = source_session(doc);
let metadata = s.active().unwrap().doc.metadata.psd_global_blocks.clone();
s.execute("edit.copy", json!({})).unwrap();
let copied = s.clipboard.as_ref().unwrap().layers.as_ref().unwrap();
assert_eq!(copied.metadata.psd_global_blocks.len(), 1);
assert_eq!(hash(&copied.metadata.psd_global_blocks[0].2), hash(&selected_record));
for _ in 0..3 {
let before = s.active().unwrap().doc.clone();
let pasted = LayerId(s.execute("edit.paste", json!({})).unwrap()["layer"].as_u64().unwrap());
assert_eq!(hash(&bytes(&s.active().unwrap().doc, pasted)), hash(&expected));
let after = s.active().unwrap().doc.clone();
assert_eq!(after.metadata.psd_global_blocks.len(), metadata.len(), "subsets should not append a second linked block");
for ((signature, key, bytes), (expected_signature, expected_key, expected_bytes)) in after.metadata.psd_global_blocks.iter().zip(&metadata) {
assert_eq!(signature, expected_signature);
assert_eq!(key, expected_key);
assert_eq!(hash(bytes), hash(expected_bytes));
}
assert!(s.undo());
assert_eq!(s.active().unwrap().doc, before);
assert!(s.redo());
assert_eq!(s.active().unwrap().doc, after);
}
let back = roundtrip(&s.active().unwrap().doc, "pcraft");
let ids: Vec<_> = back.metadata.psd_global_blocks.iter().flat_map(|(_, _, data)| photocraft_io::linked::block_uuids(data)).collect();
assert_eq!(ids.len(), 2, "one record per original file after native save/reopen");
}
#[test]
fn empty_source_ids_do_not_carry_unidentified_global_files() {
let mut private = Session::new();
private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap();
let bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap();
let mut source = Session::new();
source.execute("file.new", json!({"width":8,"height":6})).unwrap();
source.execute("layer.new.layer", json!({})).unwrap();
source.execute("edit.fill", json!({"contents":"foreground"})).unwrap();
source.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
source
.edit("malformed empty source", |doc, active| {
let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") };
sm.source = SmartSource::Linked { path: String::new() };
doc.metadata.psd_global_blocks.push((
*b"8BIM",
*b"lnk2",
Arc::new(photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
uuid: String::new(),
file_name: "unidentified-private.pcraft".into(),
bytes: bytes.clone(),
})),
));
Ok(())
})
.unwrap();
let state = source.active().unwrap();
let LayerContent::Smart(sm) = &state.doc.layer(state.active_layer.unwrap()).unwrap().content else { panic!("smart") };
assert!(photocraft_engine::smart_cmds::source_bytes(&state.doc.metadata, &sm.source).is_none(), "an empty UUID cannot reference this record");
source.execute("edit.copy", json!({})).unwrap();
source.execute("file.new", json!({"width":8,"height":6})).unwrap();
source.execute("edit.paste", json!({})).unwrap();
let saved = photocraft_format::save_to_bytes(&source.active().unwrap().doc, &Default::default()).unwrap();
let back = photocraft_format::load_from_bytes(&saved).unwrap();
assert!(back.metadata.psd_global_blocks.is_empty(), "unidentified empty-ID global bytes must not enter the copied document");
}
#[test]
fn ambiguous_invalid_utf8_ids_do_not_replace_the_clipboard() {
let mut private = Session::new();
private.execute("file.new", json!({"width":8,"height":6,"background":"black"})).unwrap();
let private_bytes = photocraft_format::save_to_bytes(&private.active().unwrap().doc, &Default::default()).unwrap();
let mut s = Session::new();
s.execute("file.new", json!({"width":8,"height":6})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("edit.fill", json!({"contents":"foreground"})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
let before_clip = s.clipboard.clone().unwrap();
s.edit("ambiguous source IDs", |doc, active| {
let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("smart") };
let SmartSource::Embedded { bytes, .. } = &sm.source else { panic!("embedded") };
let mut first = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
uuid: "x".into(),
file_name: "selected.pcraft".into(),
bytes: bytes.to_vec(),
});
let mut second = photocraft_io::linked::encode_linked_file(&photocraft_io::linked::LinkedFile {
uuid: "x".into(),
file_name: "unrelated.pcraft".into(),
bytes: private_bytes.clone(),
});
first[17] = 0xff;
second[17] = 0xfe;
first.extend(second);
sm.source = SmartSource::Linked { path: "\u{fffd}".into() };
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"lnk2", Arc::new(first)));
Ok(())
})
.unwrap();
let before_doc = s.active().unwrap().doc.clone();
let copied = s.execute("edit.copy", json!({}));
if copied.is_ok() {
let data = &s.clipboard.as_ref().unwrap().layers.as_ref().unwrap().metadata.psd_global_blocks[0].2;
let files = photocraft_io::linked::parse_linked_files(data);
assert_eq!(files.len(), 2);
assert_eq!(files[0].uuid, files[1].uuid);
eprintln!("two distinct malformed raw IDs were retained under one lossy source label");
}
let error = copied.expect_err("ambiguous source identity must fail before replacing the clipboard");
assert!(error.to_string().contains("invalid UTF-8"), "{error}");
assert_eq!(s.active().unwrap().doc, before_doc);
assert_eq!(s.clipboard.as_ref().unwrap().layers, before_clip.layers);
}
+154
View File
@@ -275,6 +275,85 @@ pub fn block_uuids(data: &[u8]) -> Vec<String> {
split_items(data).into_iter().filter_map(|(u, _)| u).collect()
}
/// Copies only records accepted by `keep` (UUID and complete raw record including padding).
/// Unlike [`rebuild_block`], unidentified records and malformed tails are discarded: clipboard
/// copies must not carry unrelated embedded files. Empty UUIDs cannot identify source bytes.
/// Selected identity must be provable without lossy UTF-8 decoding; an ambiguous or truncated
/// selected ID is an error instead. No payload decoding or filesystem access is performed.
/// `None` means the block already contains only selected complete records and can be shared.
pub fn select_items(data: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<Vec<u8>>, crate::IoError> {
let error = || crate::IoError::Unsupported("can't copy a truncated selected PSD linked-layer record".into());
let mut selected = None;
let mut at = 0usize;
while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) {
let Some((item, raw)) = framed_item(rest) else {
if let Some(item) = rest.get(8..)
&& selection_uuid(item, rest, keep)?.is_some_and(|uuid| keep(uuid, rest))
{
return Err(error());
}
if selected.is_none() {
let mut prefix = Vec::new();
append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?;
selected = Some(prefix);
}
break;
};
if selection_uuid(item, raw, keep)?.is_some_and(|uuid| keep(uuid, raw)) {
if let Some(out) = &mut selected {
append_selected(out, raw)?;
}
} else if selected.is_none() {
let mut prefix = Vec::new();
append_selected(&mut prefix, data.get(..at).ok_or_else(error)?)?;
selected = Some(prefix);
}
at = at.checked_add(raw.len()).ok_or_else(error)?;
}
Ok(selected)
}
// Legacy PSD readers retain lossy IDs for file fidelity. Selection needs exact identity;
// a lossy label is used only to refuse an ambiguous transfer, never to retain a record.
fn selection_uuid<'a>(item: &'a [u8], raw: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<&'a str>, crate::IoError> {
let Some(n) = item.get(8).map(|n| usize::from(*n)) else { return Ok(None) };
let Some(bytes) = 9usize.checked_add(n).and_then(|end| item.get(9..end)).filter(|bytes| !bytes.is_empty()) else { return Ok(None) };
match std::str::from_utf8(bytes) {
Ok(uuid) => Ok(Some(uuid)),
Err(_) if keep(&String::from_utf8_lossy(bytes), raw) => {
Err(crate::IoError::Unsupported("can't verify a PSD linked-layer source ID with invalid UTF-8".into()))
}
Err(_) => Ok(None),
}
}
/// Whether a complete raw record already occurs in a block, without decoding its file bytes.
pub fn contains_item(data: &[u8], record: &[u8]) -> bool {
let mut remaining = data;
while let Some((_, raw)) = framed_item(remaining) {
if raw == record {
return true;
}
let Some(next) = remaining.get(raw.len()..) else { return false };
remaining = next;
}
false
}
fn framed_item(data: &[u8]) -> Option<(&[u8], &[u8])> {
let len = usize::try_from(u64::from_be_bytes(data.get(..8)?.try_into().ok()?)).ok()?;
let end = len.checked_add(8)?;
let item = data.get(8..end)?;
let padded = end.checked_add(3).map(|n| n & !3)?.min(data.len());
Some((item, data.get(..padded)?))
}
fn append_selected(out: &mut Vec<u8>, bytes: &[u8]) -> Result<(), crate::IoError> {
out.try_reserve(bytes.len()).map_err(|_| crate::IoError::Unsupported("not enough memory to copy PSD linked-layer records".into()))?;
out.extend_from_slice(bytes);
Ok(())
}
/// Copies one linked item under a new UUID, preserving its embedded, external or alias payload.
pub(crate) fn copy_item(data: &[u8], uuid: &str, new_uuid: &str) -> Option<Vec<u8>> {
let (_, raw) = split_items(data).into_iter().find(|(u, _)| u.as_deref() == Some(uuid))?;
@@ -392,4 +471,79 @@ mod tests {
assert_eq!(&copy[17 + "independent-copy".len()..8 + copied_len], &raw[20..8 + original_len]);
assert!(copy_item(&raw, "missing", "new").is_none());
}
#[test]
fn selected_records_exclude_other_files_and_unidentified_pieces() {
let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
assert!(select_items(&a, &|uuid, _| uuid == "a").unwrap().is_none(), "an exclusive block can keep its original Arc");
let mut data = a.clone();
data.extend(&b);
data.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
assert_eq!(select_items(&data, &|uuid, _| uuid == "a").unwrap(), Some(a.clone()));
assert_eq!(select_items(&data, &|uuid, _| uuid == "b").unwrap(), Some(b));
assert_eq!(select_items(&data, &|_, _| false).unwrap(), Some(Vec::new()));
// A complete but unidentified item can be skipped without losing a later selected item.
let mut unreadable = 1u64.to_be_bytes().to_vec();
unreadable.extend([7, 0, 0, 0]);
unreadable.extend(&a);
assert_eq!(select_items(&unreadable, &|uuid, _| uuid == "a").unwrap(), Some(a));
}
#[test]
fn selected_external_and_alias_records_keep_unknown_payloads_verbatim() {
for kind in [*b"liFE", *b"liFA", *b"next"] {
let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] });
raw[8..12].copy_from_slice(&kind);
raw[12..16].copy_from_slice(&42u32.to_be_bytes()); // an unknown future version
let mut data = encode_linked_file(&LinkedFile { uuid: "unselected".into(), file_name: "other.psb".into(), bytes: vec![9; 3] });
data.extend(&raw);
assert_eq!(select_items(&data, &|uuid, _| uuid == "selected").unwrap(), Some(raw));
}
}
#[test]
fn truncated_selected_records_fail_instead_of_becoming_unavailable() {
let mut raw = encode_linked_file(&LinkedFile { uuid: "selected".into(), file_name: "missing.psb".into(), bytes: vec![7; 5] });
raw[..8].copy_from_slice(&u64::MAX.to_be_bytes());
assert!(select_items(&raw, &|uuid, _| uuid == "selected").unwrap_err().to_string().contains("truncated selected"));
assert_eq!(select_items(&raw, &|uuid, _| uuid == "other").unwrap(), Some(Vec::new()));
}
#[test]
fn existing_records_are_matched_individually_without_matching_other_payloads() {
let a = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
let b = encode_linked_file(&LinkedFile { uuid: "b".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
let different = encode_linked_file(&LinkedFile { uuid: "a".into(), file_name: "a.psb".into(), bytes: vec![9; 3] });
let mut combined = a.clone();
combined.extend(&b);
assert!(contains_item(&combined, &a));
assert!(contains_item(&combined, &b));
assert!(!contains_item(&combined, &different));
assert_eq!(select_items(&a, &|_, raw| !contains_item(&combined, raw)).unwrap(), Some(Vec::new()));
assert!(select_items(&different, &|_, raw| !contains_item(&combined, raw)).unwrap().is_none());
}
#[test]
fn empty_uuids_are_unidentified_even_when_requested() {
let raw = encode_linked_file(&LinkedFile { uuid: String::new(), file_name: "unidentified.psb".into(), bytes: vec![7; 5] });
assert_eq!(select_items(&raw, &|_, _| true).unwrap(), Some(Vec::new()));
let mut truncated = raw;
truncated[..8].copy_from_slice(&u64::MAX.to_be_bytes());
assert_eq!(select_items(&truncated, &|_, _| true).unwrap(), Some(Vec::new()));
}
#[test]
fn selection_never_uses_lossy_uuid_aliases_as_record_identity() {
let mut a = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "a.psb".into(), bytes: vec![1; 3] });
let mut b = encode_linked_file(&LinkedFile { uuid: "x".into(), file_name: "b.psb".into(), bytes: vec![2; 4] });
a[17] = 0xff;
b[17] = 0xfe;
let mut data = a;
data.extend(b);
assert_eq!(block_uuids(&data), vec!["\u{fffd}", "\u{fffd}"], "legacy lossy parsing remains unchanged");
assert!(select_items(&data, &|uuid, _| uuid == "\u{fffd}").unwrap_err().to_string().contains("invalid UTF-8"));
let valid = encode_linked_file(&LinkedFile { uuid: "valid".into(), file_name: "valid.psb".into(), bytes: vec![3; 5] });
data.extend(&valid);
assert_eq!(select_items(&data, &|uuid, _| uuid == "valid").unwrap(), Some(valid));
// A literal replacement character can itself be a valid UTF-8 identity.
let valid_unicode = encode_linked_file(&LinkedFile { uuid: "\u{fffd}".into(), file_name: "unicode.psb".into(), bytes: vec![4; 2] });
assert!(select_items(&valid_unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none());
}
}