forked from 1b-agent/opencadstudio-upstream
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:
1 parent
9611f40400
commit
69fa7cae4f
30 files changed
+331
-76
No files matched your search
@@ -191,6 +191,22 @@ impl OpenCADStudio {
|
||||
// Discarded — drop this tab's autosave recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||
// Web counterpart: the OPFS snapshot must go too, or the
|
||||
// drawing the user just declined to save is offered back at
|
||||
// the next boot. A snapshot round still writing this tab's key
|
||||
// would resurrect it after the remove, so defer to the round's
|
||||
// end (the same guard the save flow uses).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let key = Self::web_autosave_key(&self.tabs[idx]);
|
||||
if self.web_autosave_in_flight {
|
||||
self.web_autosave_clear_after.push(key);
|
||||
} else {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = crate::io::web_autosave::remove(&key).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
if self.tabs.len() == 1 {
|
||||
self.tab_counter += 1;
|
||||
self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
|
||||
+80
-26
@@ -1471,6 +1471,19 @@ impl OpenCADStudio {
|
||||
// Native: pick a path, then load on a worker thread. Web: the
|
||||
// browser hands back bytes, so pick + parse in one step and feed
|
||||
// the shared `FileOpened` handler directly.
|
||||
// Web guard: a second open (Ctrl+O, the OPEN command, host
|
||||
// automation) while a drop/restore/picker open is still in flight
|
||||
// would overwrite `self.opening` — the in-flight open's
|
||||
// `WebFileOpened` then misses the id check and its bytes are lost,
|
||||
// and any pending restore prompt stalls for the session. Native
|
||||
// keeps the old behaviour: its `OpenPathPicked` round-trip is
|
||||
// guarded downstream.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.opening.is_some() {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("An open is already in progress.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Task::perform(crate::io::pick_open_path(), Message::OpenPathPicked)
|
||||
@@ -2496,12 +2509,7 @@ impl OpenCADStudio {
|
||||
let mut recent_task = Task::none();
|
||||
// The name the autosave snapshot for this tab is keyed by — capture it
|
||||
// before the save re-points `current_path` at the download name.
|
||||
let autosave_key = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| self.tabs[i].tab_display_name());
|
||||
let autosave_key = Self::web_autosave_key(&self.tabs[i]);
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
|
||||
Ok(bytes) => {
|
||||
@@ -3525,6 +3533,21 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// The OPFS autosave snapshot key for `tab`: the drawing's display name
|
||||
/// (the recent copy's name), or the tab title for untitled tabs — the
|
||||
/// single derivation every web snapshot/remove site must agree on, or a
|
||||
/// remove misses the snapshot a round wrote (and vice versa).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn web_autosave_key(
|
||||
tab: &crate::app::document::DocumentTab,
|
||||
) -> String {
|
||||
tab.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| tab.tab_display_name())
|
||||
}
|
||||
|
||||
/// Periodic autosave (SAVETIME): write a `.sv$` recovery copy for every
|
||||
/// dirty tab — beside the file if it's saved, else under the temp dir — at
|
||||
/// the document's own DWG version. Best-effort and non-destructive: it never
|
||||
@@ -3580,12 +3603,7 @@ impl OpenCADStudio {
|
||||
// Key by the drawing's display name (the recent copy's name), so
|
||||
// the boot scan can compare the two; untitled tabs use the tab
|
||||
// title, which is what Save would download as.
|
||||
let name = tab
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| tab.tab_display_name());
|
||||
let name = Self::web_autosave_key(tab);
|
||||
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
|
||||
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
|
||||
let version = tab.scene.document.version;
|
||||
@@ -3725,6 +3743,21 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Defer the offered snapshot (Escape/✕ on the restore modal): unlike
|
||||
/// Discard the OPFS copy is NOT removed — one stray keystroke must not
|
||||
/// destroy the only recovery copy — it is simply re-offered on the next
|
||||
/// boot. The prompt moves on to the next queued snapshot, if any.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_autosave_defer(&mut self) -> Task<Message> {
|
||||
if !self.web_autosave_pending.is_empty() {
|
||||
self.web_autosave_pending.remove(0);
|
||||
}
|
||||
if self.web_autosave_pending.is_empty() {
|
||||
self.close_active_modal();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// The offered snapshot's bytes arrived (or did not). On success the
|
||||
/// bytes ride the 50 ms poll's guarded open queue — the poll starts an
|
||||
/// open only when the slot is free, so the recovered drawing can
|
||||
@@ -3745,7 +3778,19 @@ impl OpenCADStudio {
|
||||
// (`web_autosave_opened` / the failure hook), not here — the
|
||||
// poll has not started the open yet, so queueing now would
|
||||
// show the modal before the recovered drawing loads.
|
||||
crate::io::web_drop::enqueue_restore(open_name, bytes, key);
|
||||
// Exception: a rejected enqueue (queue full) means no open
|
||||
// will ever start for these bytes, so the prompt cycle must
|
||||
// continue here or stall for the session. The snapshot
|
||||
// survives in OPFS and is re-offered on the next boot.
|
||||
if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) {
|
||||
self.command_line.push_warning(
|
||||
crate::tf!(
|
||||
"Autosave copy could not be queued: too many pending opens"
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.queue_next_web_autosave_prompt();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -3772,7 +3817,7 @@ impl OpenCADStudio {
|
||||
|
||||
/// Delete the `.sv$` autosave recovery files for all open drawings. They
|
||||
/// exist only to survive a crash, so a clean save or exit removes them.
|
||||
pub(in crate::app) fn cleanup_autosaves(&self) {
|
||||
pub(in crate::app) fn cleanup_autosaves(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for i in 0..self.tabs.len() {
|
||||
let _ = std::fs::remove_file(self.autosave_target(i));
|
||||
@@ -3781,25 +3826,34 @@ impl OpenCADStudio {
|
||||
for path in &self.sheet_set.locks {
|
||||
crate::app::commands::sheet_set::release_lock(path);
|
||||
}
|
||||
// Web counterpart: drop each open tab's OPFS snapshot. A tab close is
|
||||
// not an exit, so this only runs on the explicit exit path; anything
|
||||
// the teardown race leaves behind is caught by the next boot scan.
|
||||
// Web counterpart: drop each open tab's OPFS snapshot, plus any
|
||||
// removes a save deferred while a snapshot round was still writing.
|
||||
// A tab close is not an exit, so this only runs on the explicit exit
|
||||
// path; anything the teardown race leaves behind is caught by the
|
||||
// next boot scan.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for tab in &self.tabs {
|
||||
let name = tab
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| tab.tab_display_name());
|
||||
{
|
||||
let mut keys = std::mem::take(&mut self.web_autosave_clear_after);
|
||||
// An in-flight round may still be writing snapshots right now;
|
||||
// its late writes would land AFTER these removes and resurrect
|
||||
// exactly what was removed, so the per-tab sweep is pointless in
|
||||
// that race — skip it entirely (the boot scan bounds leftovers:
|
||||
// `SNAPSHOT_CAP` eviction discards the oldest overflow).
|
||||
if !self.web_autosave_in_flight {
|
||||
for tab in &self.tabs {
|
||||
keys.push(Self::web_autosave_key(tab));
|
||||
}
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = crate::io::web_autosave::remove(&name).await;
|
||||
for key in &keys {
|
||||
let _ = crate::io::web_autosave::remove(key).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the autosave recovery files, then quit the application.
|
||||
pub(in crate::app) fn exit_app(&self) -> Task<Message> {
|
||||
pub(in crate::app) fn exit_app(&mut self) -> Task<Message> {
|
||||
self.cleanup_autosaves();
|
||||
iced::exit()
|
||||
}
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user