web: fix red-team breaks in restore, drop-open, and plot paths

Three attack agents (state-machine tracing, executable input fuzzing,
browser-API review) found two breaks and several degradations; all are
fixed here, with the fuzzer's 22 tests kept as regression suites.

Breaks:
- Untitled tabs (and dot-names like ".dxf") autosave as DXF, but the
  restore name defaulted to .dwg — the worker dispatches its reader by
  the name's extension, so the only copy of unsaved work could never
  be restored and was re-offered forever. recovered_name now uses the
  worker's rsplit('.') convention and defaults extensionless names to
  dxf, matching new-document serialization.
- " (recovered)" stacked one suffix per crash-restore cycle; the
  marker is now stripped idempotently before appending.
- Opening a file (Ctrl+O / OPEN / automation) while a web open was in
  flight clobbered it: the in-flight open's bytes were lost and every
  queued restore prompt stalled for the session. on_open_file now
  refuses while an open is running (web only).

Hardening:
- Escape on the Recover Drawing modal defers instead of deleting the
  only snapshot; the Discard button still removes it.
- A cap-rejected restore enqueue warns in-app and re-offers the next
  snapshot; the drop queue cap rises to 32.
- Exiting mid-round no longer races snapshot removals (deferrals are
  drained, the sweep skips while a round is in flight); closing a tab
  with "Don't save" now also removes its web snapshot; the snapshot
  key derivation is shared via web_autosave_key.
- The plot-preview overlay no longer leaks a full PDF copy per preview
  (the Download closure lives in a replaceable registry).
- A corrupt autosave index.json fails the round/boot instead of
  silently resetting every row; plot pages with non-finite or non-
  positive sheet dimensions error instead of emitting a NaN MediaBox.
- The drop filter accepts dot-less "DWG"/"dxf" names the worker
  parses (same rsplit convention on both gates).

(cherry picked from commit 3eab00bfbf)

Ported onto main (#1717 review): the five standalone tests/*_redteam.rs
files stay out — no test target builds them and they exercised copies of
the functions rather than the code itself.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 9611f40400
commit 69fa7cae4f
30 files changed
+331 -76

No files matched your search

+29 -5
View File
@@ -454,9 +454,11 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>], tot
let Some(document) = window.document() else {
return;
};
// One preview at a time: a replotted preview replaces its predecessor and
// revokes its blob URLs, so repeated previews cannot stack overlays or
// leak the images underneath.
// One preview at a time: a replotted preview replaces its predecessor,
// revokes its blob URLs and DROPS its Download closure (freeing the full
// PDF copy the closure captured — a `forget()` would leak one per
// preview), so repeated previews cannot stack overlays or leak the
// images underneath.
const OVERLAY_ID: &str = "ocs-plot-preview";
if let Some(previous) = document.get_element_by_id(OVERLAY_ID) {
previous.remove();
@@ -468,6 +470,11 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>], tot
{
let _ = web_sys::Url::revoke_object_url(&url);
}
// Take the previous closure out so it destructs BEFORE the new preview
// stores its own: removing the overlay element already released the JS
// handler reference, so this drop is the last one and actually frees
// the captured bytes.
drop(PREVIEW_DOWNLOAD_CLOSURE.with(|slot| slot.borrow_mut().take()));
if page_pngs.is_empty() {
// Rasterisation produced nothing: deliver the PDF itself rather than
// show an empty frame.
@@ -570,13 +577,16 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec<u8>], tot
let name = name.to_string();
let bytes = pdf_bytes.to_vec();
// A plain FnMut stays usable for every click; the one-shot variant
// throws on the second click, and users click Download twice.
// throws on the second click, and users click Download twice. The
// closure is HELD in a registry (not `forget()`ed): replacing or
// closing the overlay drops it and frees the captured PDF copy,
// while the cloned JS function keeps the live handler working.
let closure: wasm_bindgen::closure::Closure<dyn FnMut()> =
wasm_bindgen::closure::Closure::new(move || {
download_bytes(&name, &bytes);
});
let click: js_sys::Function = closure.as_ref().unchecked_ref::<js_sys::Function>().clone();
closure.forget();
PREVIEW_DOWNLOAD_CLOSURE.with(|slot| *slot.borrow_mut() = Some(closure));
dl.set_onclick(Some(&click));
bar.append_child(&dl).ok();
}
@@ -612,6 +622,20 @@ fn preview_urls() -> &'static std::sync::Mutex<Vec<String>> {
URLS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
// The live preview overlay's Download-button closure. The closure captures
// a full copy of the PDF bytes, so `forget()`-ing it would leak one PDF per
// preview; holding it here lets the next `show_pdf_preview` drop and free
// the previous one (the overlay element's removal already released the JS
// handler reference). A thread-local `RefCell` rather than a static
// `Mutex` like `preview_urls`: `Closure` is not `Send`, and the overlay
// only ever lives on the one UI thread.
#[cfg(target_arch = "wasm32")]
thread_local! {
static PREVIEW_DOWNLOAD_CLOSURE:
std::cell::RefCell<Option<wasm_bindgen::closure::Closure<dyn FnMut()>>> =
std::cell::RefCell::new(None);
}
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]