Skip to content

Commit 5faf5e6

Browse files
committed
Code review fixes
1 parent 1d3d32c commit 5faf5e6

9 files changed

Lines changed: 150 additions & 51 deletions

File tree

desktop/wrapper/src/handle_desktop_wrapper_message.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,15 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
3131
SaveFileDialogContext::File { content } => {
3232
dispatcher.respond(DesktopFrontendMessage::WriteFile { path, content });
3333
}
34-
SaveFileDialogContext::MultipleFiles { files } => {
35-
// Treat the chosen path as the folder name; strip any extension the user typed (e.g. "MyAnim.png" → "MyAnim").
34+
SaveFileDialogContext::MultipleFiles { files, expected_extension } => {
35+
// Treat the chosen path as the folder name. Strip only the export's expected extension (e.g. ".png" for a
36+
// PNG animation export) so that arbitrary dotted folder names like `v1.0` are preserved as-is, while a user
37+
// who typed `MyAnim.png` still gets a `MyAnim/` folder rather than a `MyAnim.png/` folder.
3638
// The `WriteFile` handler creates parent directories if they don't exist, so the folder is materialized on first write.
37-
let folder = path.with_extension("");
39+
let folder = match path.extension().and_then(|e| e.to_str()) {
40+
Some(ext) if ext.eq_ignore_ascii_case(&expected_extension) => path.with_extension(""),
41+
_ => path,
42+
};
3843
for (filename, content) in files {
3944
let file_path = folder.join(&filename);
4045
dispatcher.respond(DesktopFrontendMessage::WriteFile { path: file_path, content });

desktop/wrapper/src/intercept_frontend_message.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,16 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
7171
// Materialize each frame to bytes; SVG strings are encoded as UTF-8.
7272
// Raster-needs-canvas-rasterize frames can't be encoded here without a Rust SVG rasterizer,
7373
// so fall through to the frontend zip path in that case.
74+
// TODO: This fallback is inconsistent with the desktop folder-save flow for the rest of the animation
75+
// export — desktop users will see a .zip download instead of a folder. Once SVG rasterization moves to
76+
// Rust (resvg), this fallback can be removed and all frame paths can save into the chosen folder.
7477
let mut needs_frontend_rasterization = false;
7578
let mut materialized = Vec::with_capacity(frames.len());
79+
// Dynamic zero-pad width so files keep sorting in playback order beyond 9,999 frames.
80+
let pad_width = frames.len().to_string().len().max(4);
81+
let safe_base = graphite_editor::messages::frontend::utility_types::sanitize_filename_component(&name);
7682
for (index, frame) in frames.iter().enumerate() {
77-
let filename = format!("{name}_{:04}.{extension}", index + 1);
83+
let filename = format!("{safe_base}_{:0pad$}.{extension}", index + 1, pad = pad_width);
7884
let bytes = match frame {
7985
ExportAnimationFrame::Svg(svg) if extension == "svg" => svg.as_bytes().to_vec(),
8086
ExportAnimationFrame::Bytes(bytes) => bytes.to_vec(),
@@ -100,10 +106,13 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
100106
// The dialog name is the folder the frames go into (analogous to the .zip on web).
101107
dispatcher.respond(DesktopFrontendMessage::SaveFileDialog {
102108
title: "Save Animation Frames Folder As".to_string(),
103-
default_filename: name.clone(),
109+
default_filename: safe_base,
104110
default_folder: folder,
105111
filters: Vec::new(),
106-
context: SaveFileDialogContext::MultipleFiles { files: materialized },
112+
context: SaveFileDialogContext::MultipleFiles {
113+
files: materialized,
114+
expected_extension: extension,
115+
},
107116
});
108117
}
109118
FrontendMessage::TriggerVisitLink { url } => {

desktop/wrapper/src/messages.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,13 @@ pub enum SaveFileDialogContext {
118118
File {
119119
content: Vec<u8>,
120120
},
121-
/// Multiple files written into a folder whose path is the user-chosen path with any extension stripped
122-
/// (e.g. picking `MyAnim.png` yields a `MyAnim/` folder). Each `(filename, content)` entry is written
121+
/// Multiple files written into a folder whose path is the user-chosen path with the matching `expected_extension`
122+
/// stripped (e.g. for a PNG animation export, picking `MyAnim.png` yields a `MyAnim/` folder, while picking `v1.0`
123+
/// is preserved as `v1.0/` because `.0` isn't the expected extension). Each `(filename, content)` entry is written
123124
/// inside that folder, which is created if it doesn't exist.
124125
MultipleFiles {
125126
files: Vec<(String, Vec<u8>)>,
127+
expected_extension: String,
126128
},
127129
}
128130

editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,21 @@ impl Default for ExportDialogMessageHandler {
3838
}
3939
}
4040

41+
/// Upper bound on how many frames a single animation export will queue. Each frame is rendered then held
42+
/// in memory until the whole batch ships to the frontend, so we cap to avoid pathological allocations from
43+
/// large time ranges. Tuned for typical animation lengths (10k frames is ~5.5 min at 30 fps, ~2.8 min at 60 fps).
44+
pub const ANIMATION_EXPORT_MAX_FRAMES: u32 = 10_000;
45+
4146
impl ExportDialogMessageHandler {
4247
fn total_frames(&self) -> u32 {
48+
// Sanitize against non-finite values that could otherwise propagate into `Duration::from_secs_f64` and panic.
49+
if !self.fps.is_finite() || self.fps <= 0. || !self.start_seconds.is_finite() || !self.end_seconds.is_finite() {
50+
return 1;
51+
}
4352
let duration = (self.end_seconds - self.start_seconds).max(0.);
44-
((duration * self.fps).round() as i64).max(1) as u32
53+
// `ceil` so a duration slightly longer than a frame multiple still gets the trailing frame.
54+
let raw = (duration * self.fps).ceil() as i64;
55+
raw.clamp(1, ANIMATION_EXPORT_MAX_FRAMES as i64) as u32
4556
}
4657
}
4758

@@ -55,14 +66,19 @@ impl MessageHandler<ExportDialogMessage, ExportDialogMessageContext<'_>> for Exp
5566
ExportDialogMessage::ScaleFactor { factor } => self.scale_factor = factor,
5667
ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds,
5768
ExportDialogMessage::Animated { animated } => self.animated = animated,
58-
ExportDialogMessage::Fps { fps } => self.fps = fps.max(0.001),
69+
ExportDialogMessage::Fps { fps } => {
70+
// Reject non-finite/non-positive values so we never feed NaN or infinity into duration math downstream.
71+
self.fps = if fps.is_finite() && fps > 0. { fps } else { 0.001 };
72+
}
5973
ExportDialogMessage::StartSeconds { start } => {
60-
self.start_seconds = start.max(0.);
74+
self.start_seconds = if start.is_finite() { start.max(0.) } else { 0. };
6175
if self.end_seconds < self.start_seconds {
6276
self.end_seconds = self.start_seconds;
6377
}
6478
}
65-
ExportDialogMessage::EndSeconds { end } => self.end_seconds = end.max(self.start_seconds),
79+
ExportDialogMessage::EndSeconds { end } => {
80+
self.end_seconds = if end.is_finite() { end.max(self.start_seconds) } else { self.start_seconds };
81+
}
6682

6783
ExportDialogMessage::Submit => {
6884
// Fall back to "All Artwork" if "Selection" was chosen but nothing is currently selected

editor/src/messages/frontend/utility_types.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,23 @@ pub enum ExportAnimationFrame {
111111
Bytes(serde_bytes::ByteBuf),
112112
}
113113

114+
/// Replace characters that have special meaning in filesystem paths (or are otherwise unsafe in filenames) with `_`.
115+
/// Used to neutralize user-controlled strings — document names, artboard names — before they become export filenames,
116+
/// so a name like `..\evil` or `foo/bar` can't escape the export folder or create nested zip entries.
117+
pub fn sanitize_filename_component(name: &str) -> String {
118+
let cleaned: String = name
119+
.chars()
120+
.map(|c| match c {
121+
'/' | '\\' | ':' | '*' | '?' | '<' | '>' | '|' | '"' | '\0' => '_',
122+
c if c.is_control() => '_',
123+
c => c,
124+
})
125+
.collect();
126+
// Strip leading/trailing dots and whitespace; Windows treats trailing `.` / ` ` as hidden and `..` is the parent dir.
127+
let trimmed = cleaned.trim().trim_matches('.').trim();
128+
if trimmed.is_empty() { "Untitled".to_string() } else { trimmed.to_string() }
129+
}
130+
114131
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
115132
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
116133
pub struct EyedropperPreviewImage {

editor/src/node_graph_executor.rs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,17 @@ impl NodeGraphExecutor {
303303
.map_err(|e| e.to_string())?;
304304

305305
if let Some(animation) = export_config.animation {
306-
// Allocate an export ID and accumulator, then queue one execution per frame
306+
// Defense-in-depth: the dialog already validates these, but reject non-finite/non-positive values here
307+
// too so a corrupt message can't reach `Duration::from_secs_f64` (which panics on NaN/negative/huge).
308+
if !animation.fps.is_finite() || animation.fps <= 0. || !animation.start_seconds.is_finite() {
309+
return Err("Animation export rejected: fps and start time must be finite, with fps > 0".to_string());
310+
}
311+
312+
// Allocate an export ID and accumulator, then queue one execution per frame.
313+
// TODO: All encoded frames are held in `pending_animation_exports` until the last frame arrives, so peak
314+
// memory grows with `total_frames * encoded_frame_size`. For long/high-resolution animations, this could
315+
// exhaust memory. A bounded cap is enforced upstream in the export dialog (ANIMATION_EXPORT_MAX_FRAMES),
316+
// but a true fix would stream frames out incrementally instead of accumulating.
307317
let export_id = self.next_animation_export_id;
308318
self.next_animation_export_id = self.next_animation_export_id.wrapping_add(1);
309319

@@ -325,7 +335,10 @@ impl NodeGraphExecutor {
325335

326336
for frame_index in 0..animation.total_frames {
327337
let frame_seconds = animation.frame_time_seconds(frame_index);
328-
let animation_time = Duration::from_secs_f64(frame_seconds.max(0.));
338+
// `Duration::from_secs_f64` panics on negative/NaN/huge values; clamp defensively (we've already
339+
// validated `fps`/`start_seconds` above, but a far-future `start_seconds` could still overflow).
340+
let safe_seconds = if frame_seconds.is_finite() { frame_seconds.clamp(0., 1e9) } else { 0. };
341+
let animation_time = Duration::from_secs_f64(safe_seconds);
329342
let timing = TimingInformation { time: frame_seconds, animation_time };
330343

331344
let frame_render_config = RenderConfig { time: timing, ..base_render_config };
@@ -382,6 +395,24 @@ impl NodeGraphExecutor {
382395
document.network_interface.update_click_targets(HashMap::new());
383396
document.network_interface.update_outlines(HashMap::new());
384397
document.network_interface.update_vector_modify(HashMap::new());
398+
399+
// If this failure belongs to an animation export, drop its accumulator so the partially
400+
// rendered frames don't stay pinned in memory. Subsequent failed frames for the same
401+
// export are then silently ignored by `process_animation_frame`.
402+
// TODO: An export can also leak if it's interrupted by something *outside* this error
403+
// path — e.g. the document is closed mid-export. A proper fix would route a
404+
// cancellation through `pending_animation_exports`. Tracked separately.
405+
let leaked_export_id = self
406+
.futures
407+
.iter()
408+
.find(|(fid, _)| *fid == execution_id)
409+
.and_then(|(_, ctx)| ctx.export_config.as_ref())
410+
.and_then(|cfg| cfg.animation_frame)
411+
.map(|af| af.export_id);
412+
if let Some(export_id) = leaked_export_id {
413+
self.pending_animation_exports.remove(&export_id);
414+
}
415+
385416
return Err(format!("Node graph evaluation failed:\n{e}"));
386417
}
387418
};
@@ -609,11 +640,19 @@ impl NodeGraphExecutor {
609640
TaggedValue::RenderOutput(RenderOutput {
610641
data: RenderOutputType::Buffer { data, width, height },
611642
..
612-
}) if file_type != FileType::Svg => {
613-
let encoded = encode_raster_buffer(file_type, data, width, height)?;
614-
ExportAnimationFrame::Bytes(serde_bytes::ByteBuf::from(encoded))
643+
}) if file_type != FileType::Svg => match encode_raster_buffer(file_type, data, width, height) {
644+
Ok(encoded) => ExportAnimationFrame::Bytes(serde_bytes::ByteBuf::from(encoded)),
645+
Err(err) => {
646+
// Drop the partial accumulator so its already-received frames don't leak.
647+
self.pending_animation_exports.remove(&animation_frame.export_id);
648+
return Err(err);
649+
}
650+
},
651+
other => {
652+
// Drop the partial accumulator so its already-received frames don't leak.
653+
self.pending_animation_exports.remove(&animation_frame.export_id);
654+
return Err(format!("Incorrect render type for animation frame ({file_type:?}, {other})"));
615655
}
616-
other => return Err(format!("Incorrect render type for animation frame ({file_type:?}, {other})")),
617656
};
618657

619658
let Some(accumulator) = self.pending_animation_exports.get_mut(&animation_frame.export_id) else {
@@ -635,9 +674,11 @@ impl NodeGraphExecutor {
635674

636675
// All frames received: drain the accumulator and emit a single message
637676
let accumulator = self.pending_animation_exports.remove(&animation_frame.export_id).expect("Accumulator was present");
638-
let base_name = match (accumulator.artboard_name, accumulator.artboard_count) {
639-
(Some(artboard_name), count) if count > 1 => format!("{} - {}", accumulator.name, artboard_name),
640-
_ => accumulator.name,
677+
// Sanitize before the name reaches filesystem joins or zip entry names downstream.
678+
let safe_doc_name = crate::messages::frontend::utility_types::sanitize_filename_component(&accumulator.name);
679+
let base_name = match (accumulator.artboard_name.as_deref(), accumulator.artboard_count) {
680+
(Some(artboard_name), count) if count > 1 => format!("{safe_doc_name} - {}", crate::messages::frontend::utility_types::sanitize_filename_component(artboard_name)),
681+
_ => safe_doc_name,
641682
};
642683
let frames: Vec<_> = accumulator
643684
.frames

frontend/src/stores/portfolio.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,28 +140,29 @@ export function createPortfolioStore(subscriptions: SubscriptionsRouter, editor:
140140
const backgroundColor = mime.endsWith("jpeg") ? "white" : undefined;
141141
const padWidth = Math.max(4, String(frames.length).length);
142142

143-
// Materialize each frame to bytes, rasterizing SVG via canvas when the destination format is raster
143+
// Materialize each frame to bytes, rasterizing SVG via canvas when the destination format is raster.
144+
// Any per-frame failure aborts the export rather than silently dropping frames, so the user never gets
145+
// a zip with mismatched indices vs. the requested playback range.
144146
const entries: [string, Uint8Array][] = [];
145-
for (let i = 0; i < frames.length; i++) {
146-
const frame = frames[i];
147-
const filename = `${name}_${String(i + 1).padStart(padWidth, "0")}.${extension}`;
148-
149-
let bytes: Uint8Array;
150-
if ("Bytes" in frame) {
151-
bytes = frame.Bytes;
152-
} else if (isRaster) {
153-
let blob: Blob;
154-
try {
155-
blob = await rasterizeSVG(frame.Svg, size[0], size[1], mime, backgroundColor);
156-
} catch {
157-
// Skip frames that fail to rasterize (e.g. zero-sized) rather than aborting the whole export
158-
continue;
147+
try {
148+
for (let i = 0; i < frames.length; i++) {
149+
const frame = frames[i];
150+
const filename = `${name}_${String(i + 1).padStart(padWidth, "0")}.${extension}`;
151+
152+
let bytes: Uint8Array;
153+
if ("Bytes" in frame) {
154+
bytes = frame.Bytes;
155+
} else if (isRaster) {
156+
const blob = await rasterizeSVG(frame.Svg, size[0], size[1], mime, backgroundColor);
157+
bytes = new Uint8Array(await blob.arrayBuffer());
158+
} else {
159+
bytes = new TextEncoder().encode(frame.Svg);
159160
}
160-
bytes = new Uint8Array(await blob.arrayBuffer());
161-
} else {
162-
bytes = new TextEncoder().encode(frame.Svg);
161+
entries.push([filename, bytes]);
163162
}
164-
entries.push([filename, bytes]);
163+
} catch (error) {
164+
editor.errorDialog("Animation export failed", error instanceof Error ? error.message : String(error));
165+
return;
165166
}
166167

167168
if (entries.length === 0) return;

frontend/src/utility-functions/rasterization.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,23 @@ export async function rasterizeSVGCanvas(svg: string, width: number, height: num
1717
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
1818
const url = URL.createObjectURL(svgBlob);
1919

20-
// Load the Image from the URL and wait until it's done
20+
// Load the Image from the URL and wait until it's done. Reject on error so callers don't hang forever
21+
// if the SVG fails to decode (malformed markup, zero-sized viewport with no width/height attrs, etc.).
2122
const image = new Image();
2223
image.src = url;
23-
await new Promise<void>((resolve) => {
24-
image.onload = () => resolve();
25-
});
26-
27-
// Draw our SVG to the canvas
28-
context?.drawImage(image, 0, 0, width, height);
29-
30-
// Clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage collected after `svgBlob` goes out of scope)
31-
URL.revokeObjectURL(url);
24+
try {
25+
await new Promise<void>((resolve, reject) => {
26+
image.onload = () => resolve();
27+
image.onerror = () => reject(new Error("Failed to decode SVG for rasterization"));
28+
});
29+
30+
// Draw our SVG to the canvas
31+
context?.drawImage(image, 0, 0, width, height);
32+
} finally {
33+
// Always clean up the SVG blob URL (once the URL is revoked, the SVG blob data itself is garbage
34+
// collected after `svgBlob` goes out of scope), even if loading or drawing threw.
35+
URL.revokeObjectURL(url);
36+
}
3237

3338
return canvas;
3439
}

frontend/wrapper/src/editor_wrapper.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -996,10 +996,13 @@ pub fn create_zip_from_files(entries: js_sys::Array) -> Result<Vec<u8>, JsValue>
996996
.ok_or_else(|| JsValue::from_str("createZipFromFiles: each entry must be a [filename, bytes] array"))?;
997997

998998
let filename = pair.get(0).as_string().ok_or_else(|| JsValue::from_str("createZipFromFiles: filename must be a string"))?;
999+
// Defense in depth: callers should already sanitize, but if a stray path separator leaks through,
1000+
// replace it here so the entry can't become a nested path inside the archive.
1001+
let safe_filename: String = filename.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
9991002
let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec();
10001003

10011004
writer
1002-
.start_file(filename, options)
1005+
.start_file(safe_filename, options)
10031006
.map_err(|e| JsValue::from_str(&format!("createZipFromFiles: start_file failed: {e}")))?;
10041007
writer.write_all(&bytes).map_err(|e| JsValue::from_str(&format!("createZipFromFiles: write_all failed: {e}")))?;
10051008
}

0 commit comments

Comments
 (0)