diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 2a8ce8366a..a0923a6744 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -935,6 +935,43 @@ mod tests { std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn workspace_layer_exports_keep_linked_smart_objects_as_pixels() { + use photocraft_engine::doc::{LayerContent, SmartSource}; + + const MARKER: &[u8] = b"PHOTOCRAFT-LINKED-0002"; + let has_marker = |bytes: &[u8]| bytes.windows(MARKER.len()).any(|w| w == MARKER); + let base = std::env::temp_dir().join(format!("pc-export-layers-linked-{}", std::process::id())); + let root = base.join("workspace"); + std::fs::create_dir_all(&root).unwrap(); + let outside = base.join("outside.txt"); + std::fs::write(&outside, MARKER).unwrap(); + // A document whose smart object names a file outside the roots as its source. + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + session.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let state = session.active_mut().unwrap(); + let LayerContent::Smart(smart) = &mut std::sync::Arc::make_mut(&mut state.doc).layers[0].content else { panic!("smart object") }; + smart.source = SmartSource::Linked { path: outside.to_string_lossy().into_owned() }; + std::fs::write(root.join("in.pcraft"), photocraft_engine::smart_cmds::encode_source(&state.doc).unwrap()).unwrap(); + + let mut h = Headless::with_workspace(crate::AuthorizedWorkspace::new(Some(&root), Some(&root)).unwrap()); + h.handle("doc.open", json!({"path": "in.pcraft"})).unwrap(); + let out = root.join("layers"); + for format in ["psd", "psb"] { + let exported = h.handle("doc.exportLayers", json!({"dir": out.to_string_lossy(), "format": format})).unwrap(); + assert_eq!(exported["count"], 1, "{exported}"); + } + let files: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).collect(); + assert_eq!(files.len(), 2, "{files:?}"); + for file in files { + assert!(!has_marker(&std::fs::read(&file).unwrap()), "{} embeds no file outside the roots", file.display()); + } + // Release directory capabilities before cleanup on Windows. + drop(h); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn trusted_local_render_keeps_full_size_behavior() { let mut h = Headless::trusted_local(); diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index e078859933..ebc7c49640 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1681,6 +1681,9 @@ pub fn export_layers_with(s: &Session, p: &Value, mut write: impl FnMut(&str, &[ let cmd = "file.export.layersToFiles"; let format = p.get("format").and_then(Value::as_str).unwrap_or("png").trim_start_matches('.').to_ascii_lowercase(); let visible_only = p.get("visibleOnly").and_then(Value::as_bool).unwrap_or(true); + // Callers outside `Session::execute` (headless workspace exports) carry the session's gate + // into `encode`, so a PSD embeds no file a linked smart object names. + let _authorization = crate::jobs::render_authorization_scope(s.authorize); let save = SaveOpts::from_params(s, p, cmd)?; let d = s.active().ok_or(EngineError::NoDocument)?; let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));