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

+12 -5
View File
@@ -1136,12 +1136,15 @@ impl OpenCADStudio {
// not be clobbered (its bytes are gone once dequeued), so the
// entry goes back to the queue for the next poll tick.
if self.opening.is_some() {
match restore_key {
// A full queue rejects the bytes here; io-side console
// warning only (the drop path has no user-facing
// surface for rejections by design — see QUEUE_CAP).
let _ = match restore_key {
Some(key) => {
crate::io::web_drop::enqueue_restore(name, bytes, key)
}
None => crate::io::web_drop::enqueue_open(name, bytes),
}
};
return Task::none();
}
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
@@ -1312,6 +1315,8 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDiscard => self.on_web_autosave_discard(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveDefer => self.on_web_autosave_defer(),
#[cfg(target_arch = "wasm32")]
Message::WebAutosaveBytes(name, key, result) => {
self.on_web_autosave_bytes(name, key, result)
}
@@ -9022,11 +9027,13 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::Options) {
return self.update(Message::OptionsClose);
}
// Closing the crash-recovery prompt (✕ or Escape) discards
// the offered snapshot — otherwise it would requeue itself.
// Closing the crash-recovery prompt (✕ or Escape) DEFERS:
// one stray keystroke must not delete the only recovery
// copy, so the snapshot survives and is re-offered on the
// next boot. Only the Discard button deletes.
#[cfg(target_arch = "wasm32")]
if self.active_modal == Some(super::ModalKind::AutosaveRestore) {
return self.update(Message::WebAutosaveDiscard);
return self.update(Message::WebAutosaveDefer);
}
// Closing the shortcut editor with un-applied rows needs an
// explicit discard; a second close attempt (or the overlay's