From fb674fc45e66b21722dc687a0b5e0a489f02623f Mon Sep 17 00:00:00 2001 From: Afonsop Date: Mon, 12 Oct 2026 11:45:45 +0300 Subject: [PATCH] fix: recovered_name must not smuggle path separators through the extension recovered_name reduced only the head to its final path component, so a stored name like "a.b/c" came back as "a (recovered).b/c". The whole input is now sanitized first, and the empty-stem fallback uses the sanitized component, which also restores idempotence for separator-headed names ("/.dwg" used to alternate across crash cycles). Ported from 9262d63f (#1717); its integer-precision fix landed with the d719fb77 port, and the standalone tests/fuzz_round.rs suite (copies of the functions rather than the code) stays out. (cherry picked from commit 9262d63f) --- src/io/web_autosave.rs | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index 5939d2ea9..69afee9a7 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -101,21 +101,21 @@ const RECOVERED_MARKER: &str = " (recovered)"; /// recovered name. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native pub(crate) fn recovered_name(drawing: &str) -> String { - let (head, ext) = match drawing.rsplit_once('.') { - Some((head, ext)) if !ext.is_empty() => (head, ext.to_string()), - // No dot (or a trailing one): the whole name is the stem. - _ => (drawing, "dxf".to_string()), - }; // A hostile stored name must not smuggle path separators into the tab - // title / download name: keep only the final component of the head. - let head = head.rsplit(['/', '\\']).next().unwrap_or_default(); - // When the split consumed everything (".dxf") fall back to the whole - // stored name, like `Path::file_stem`'s empty-stem fallback did. - let mut stem = if head.is_empty() { - drawing - } else { - head + // title / download name — through the head OR the extension ("a.b/c" + // keeps "/c" after the last dot): reduce the WHOLE input to its final + // path component first, then split stem/extension. + let name = drawing.rsplit(['/', '\\']).next().unwrap_or_default(); + let (head, ext) = match name.rsplit_once('.') { + Some((head, ext)) if !ext.is_empty() => (head, ext.to_string()), + // No dot (or a trailing one): the whole component is the stem. + _ => (name, "dxf".to_string()), }; + // An empty stem (".dxf" — a DXF name under the worker's rsplit + // convention) keeps the whole component as the stem so the extension + // does not flip, and the result stays idempotent because the marker + // lives in the stem. + let mut stem = if head.is_empty() { name } else { head }; // Idempotent: strip every trailing marker, then append exactly one. while let Some(stripped) = stem.strip_suffix(RECOVERED_MARKER) { stem = stripped; @@ -311,6 +311,19 @@ mod tests { assert!(newer_than_saved(1.0, None)); } + #[test] + fn recovered_names_never_smuggle_separators_or_drift() { + // Fuzz round: the extension could smuggle a path ("a.b/c") and the + // empty-stem fallback reinstated the unsanitized input, breaking + // idempotence across crash cycles. + let once = recovered_name("a.b/c"); + assert_eq!(once, "c (recovered).dxf", "final path component, no separator survives"); + assert_eq!(recovered_name(&once), once, "idempotent"); + let dotfile = recovered_name("/.dwg"); + assert_eq!(dotfile, ".dwg (recovered).dwg"); + assert_eq!(recovered_name(&dotfile), dotfile, "idempotent"); + } + #[test] fn recovered_names_keep_a_parseable_extension() { assert_eq!(recovered_name("plan.dwg"), "plan (recovered).dwg");