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).
This commit is contained in:
Afonsop committed 2026-09-16 13:56:08 +01:00
1 parent 64160fd68d
commit 3eab00bfbf
35 files changed
+1356 -76

No files matched your search

+2
View File
@@ -279,6 +279,8 @@ common =
.autosave-copy-could-not-be-stored = تعذر تخزين نسخة الحفظ التلقائي: __ocs_fmt_0__
.restored-from-the-autosave-copy = تم استرداد "__ocs_fmt_0__" من نسخة الحفظ التلقائي
.autosave-copy-could-not-be-read = تعذر قراءة نسخة الحفظ التلقائي: __ocs_fmt_0__
.an-open-is-already-in-progress = هناك عملية فتح جارية بالفعل.
.autosave-copy-could-not-be-queued-too-many-pending-opens = تعذر وضع نسخة الحفظ التلقائي في قائمة الانتظار: عدد عمليات الفتح المعلقة كبير جدًا
.jogged-radius = نصف قطر منكسر
.none-use-shortcuts-set-key-command = (لا شيء — استخدم: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = المحاذاة:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Копието от автоматичното записване не можа да бъде съхранено: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" беше възстановен от копието от автоматичното записване
.autosave-copy-could-not-be-read = Копието от автоматичното записване не можа да бъде прочетено: __ocs_fmt_0__
.an-open-is-already-in-progress = Вече се отваря файл.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Копието от автоматичното записване не можа да бъде поставено на опашка: твърде много чакащи отваряния
.jogged-radius = Радиус с чупка
.none-use-shortcuts-set-key-command = (няма — използвай: SHORTCUTS SET <клавиш> <команда>)
.alignment-indented-label-title-case = Подравняване:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Kopii automatického uložení nelze uložit: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" byl obnoven z kopie automatického uložení
.autosave-copy-could-not-be-read = Kopii automatického uložení nelze přečíst: __ocs_fmt_0__
.an-open-is-already-in-progress = Otevírání souboru již probíhá.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Kopii automatického uložení nelze zařadit do fronty: příliš mnoho čekajících otevření
.jogged-radius = Zalomený poloměr
.none-use-shortcuts-set-key-command =(žádné — použijte: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Zarovnání:
+2
View File
@@ -267,6 +267,8 @@ common =
.autosave-copy-could-not-be-stored = Automatische Sicherungskopie konnte nicht gespeichert werden: __ocs_fmt_0__
.restored-from-the-autosave-copy = „__ocs_fmt_0__“ wurde aus der automatischen Sicherungskopie wiederhergestellt
.autosave-copy-could-not-be-read = Automatische Sicherungskopie konnte nicht gelesen werden: __ocs_fmt_0__
.an-open-is-already-in-progress = Es wird bereits eine Datei geöffnet.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Automatische Sicherungskopie konnte nicht in die Warteschlange gestellt werden: zu viele ausstehende Öffnungsvorgänge
.jogged-radius = Geknickter Radius
.none-use-shortcuts-set-key-command = (keine — verwenden: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Ausrichtung:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Δεν ήταν δυνατή η αποθήκευση του αντιγράφου αυτόματης αποθήκευσης: __ocs_fmt_0__
.restored-from-the-autosave-copy = Το "__ocs_fmt_0__" επαναφέρθηκε από το αντίγραφο αυτόματης αποθήκευσης
.autosave-copy-could-not-be-read = Δεν ήταν δυνατή η ανάγνωση του αντιγράφου αυτόματης αποθήκευσης: __ocs_fmt_0__
.an-open-is-already-in-progress = Μια διαδικασία ανοίγματος βρίσκεται ήδη σε εξέλιξη.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Το αντίγραφο αυτόματης αποθήκευσης δεν μπόρεσε να μπει στην ουρά: υπερβολικά πολλές εκκρεμείς ανοίξεις
.revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν.
.revolve-no-selected-object-could-be-revolved = REVOLVE: κανένα επιλεγμένο αντικείμενο δεν μπόρεσε να περιστραφεί με τις ζητούμενες επιλογές.
.none-use-shortcuts-set-key-command = (κανένα — χρησιμοποιήστε: SHORTCUTS SET <πλήκτρο> <εντολή>)
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Autosave copy could not be stored: __ocs_fmt_0__
.restored-from-the-autosave-copy = Restored "__ocs_fmt_0__" from the autosave copy
.autosave-copy-could-not-be-read = Autosave copy could not be read: __ocs_fmt_0__
.an-open-is-already-in-progress = An open is already in progress.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Autosave copy could not be queued: too many pending opens
.none-use-shortcuts-set-key-command = (none — use: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Alignment:
.background-fill-enabled = Background fill enabled
+2
View File
@@ -267,6 +267,8 @@ common =
.autosave-copy-could-not-be-stored = No se pudo guardar la copia de autoguardado: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" restaurado desde la copia de autoguardado
.autosave-copy-could-not-be-read = No se pudo leer la copia de autoguardado: __ocs_fmt_0__
.an-open-is-already-in-progress = Ya hay una apertura en curso.
.autosave-copy-could-not-be-queued-too-many-pending-opens = No se pudo poner en cola la copia de autoguardado: demasiadas aperturas pendientes
.jogged-radius = Radio con recodo
.none-use-shortcuts-set-key-command = (ninguno — use: SHORTCUTS SET <tecla> <comando>)
.alignment-indented-label-title-case = Alineación:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Automaattisen tallennuksen kopiota ei voitu tallentaa: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" palautettiin automaattisen tallennuksen kopiosta
.autosave-copy-could-not-be-read = Automaattisen tallennuksen kopiota ei voitu lukea: __ocs_fmt_0__
.an-open-is-already-in-progress = Tiedoston avaaminen on jo käynnissä.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Automaattisen tallennuksen kopiota ei voitu jonottaa: liikaa odottavia avauksia
.jogged-radius = Katkaistu säde
.none-use-shortcuts-set-key-command =(ei mitään — käytä: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Tasaus:
+2
View File
@@ -267,6 +267,8 @@ common =
.autosave-copy-could-not-be-stored = Impossible de stocker la copie de sauvegarde automatique : __ocs_fmt_0__
.restored-from-the-autosave-copy = «__ocs_fmt_0__» restauré depuis la copie de sauvegarde automatique
.autosave-copy-could-not-be-read = Impossible de lire la copie de sauvegarde automatique : __ocs_fmt_0__
.an-open-is-already-in-progress = Une ouverture est déjà en cours.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Impossible de mettre la copie de sauvegarde automatique en file d'attente : trop d'ouvertures en attente
.jogged-radius = Rayon raccourci
.none-use-shortcuts-set-key-command = (aucune utilisation: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Alignement :
+2
View File
@@ -255,6 +255,8 @@ common =
.autosave-copy-could-not-be-stored = ऑटोसेव प्रतिलिपि संग्रहित नहीं की जा सकी: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" को ऑटोसेव प्रतिलिपि से पुनर्स्थापित किया गया
.autosave-copy-could-not-be-read = ऑटोसेव प्रतिलिपि पढ़ी नहीं जा सकी: __ocs_fmt_0__
.an-open-is-already-in-progress = एक फ़ाइल पहले से ही खोली जा रही है।
.autosave-copy-could-not-be-queued-too-many-pending-opens = ऑटोसेव प्रतिलिपि कतार में नहीं रखी जा सकी: बहुत अधिक लंबित खोलने की क्रियाएँ
.jogged-radius = मोड़ वाला त्रिज्या माप
.none-use-shortcuts-set-key-command = (कोई नहीं — उपयोग: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = संरेखण:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Az automatikus mentési másolatot nem sikerült tárolni: __ocs_fmt_0__
.restored-from-the-autosave-copy = A(z) "__ocs_fmt_0__" helyreállítva az automatikus mentési másolatból
.autosave-copy-could-not-be-read = Az automatikus mentési másolatot nem sikerült elolvasni: __ocs_fmt_0__
.an-open-is-already-in-progress = Már folyamatban van egy megnyitás.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Az automatikus mentési másolatot nem sikerült sorba állítani: túl sok függőben lévő megnyitás
.jogged-radius = Tört sugarú méret
.none-use-shortcuts-set-key-command =(nincs – használat: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Igazítás:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Impossibile salvare la copia di salvataggio automatico: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" ripristinato dalla copia di salvataggio automatico
.autosave-copy-could-not-be-read = Impossibile leggere la copia di salvataggio automatico: __ocs_fmt_0__
.an-open-is-already-in-progress = Un'apertura è già in corso.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Impossibile accodare la copia di salvataggio automatico: troppe aperture in sospeso
.jogged-radius = Raggio spezzato
.none-use-shortcuts-set-key-command =(nessuno — utilizzare: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Allineamento:
+2
View File
@@ -257,6 +257,8 @@ common =
.autosave-copy-could-not-be-stored = 自動保存コピーを保存できませんでした: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" を自動保存コピーから復元しました
.autosave-copy-could-not-be-read = 自動保存コピーを読み込めませんでした: __ocs_fmt_0__
.an-open-is-already-in-progress = 開く操作がすでに進行中です。
.autosave-copy-could-not-be-queued-too-many-pending-opens = 自動保存コピーをキューに入れられませんでした:保留中の開く操作が多すぎます
.jogged-radius = 折れ曲がり半径
.none-use-shortcuts-set-key-command = (なし — SHORTCUTS SET <key> <command> を使用)
.alignment-indented-label-title-case = 配置:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = 자동 저장 사본을 저장할 수 없습니다: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__"을(를) 자동 저장 사본에서 복원했습니다
.autosave-copy-could-not-be-read = 자동 저장 사본을 읽을 수 없습니다: __ocs_fmt_0__
.an-open-is-already-in-progress = 열기 작업이 이미 진행 중입니다.
.autosave-copy-could-not-be-queued-too-many-pending-opens = 자동 저장 사본을 큐에 넣지 못했습니다: 대기 중인 열기가 너무 많습니다
.jogged-radius = 꺾인 반지름
.none-use-shortcuts-set-key-command = (없음 — 사용: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case =정렬:
+2
View File
@@ -267,6 +267,8 @@ common =
.autosave-copy-could-not-be-stored = Automatische opslagkopie kon niet worden bewaard: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" is hersteld uit de automatische opslagkopie
.autosave-copy-could-not-be-read = Automatische opslagkopie kon niet worden gelezen: __ocs_fmt_0__
.an-open-is-already-in-progress = Er wordt al een bestand geopend.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Automatische opslagkopie kon niet in de wachtrij worden geplaatst: te veel wachtende openingen
.jogged-radius = Geknikte straal
.none-use-shortcuts-set-key-command = (geen gebruik: SHORTCUTS SET <toets> <opdracht>)
.alignment-indented-label-title-case = Uitlijning:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = Nie można zapisać kopii autozapisu: __ocs_fmt_0__
.restored-from-the-autosave-copy = Przywrócono "__ocs_fmt_0__" z kopii autozapisu
.autosave-copy-could-not-be-read = Nie można odczytać kopii autozapisu: __ocs_fmt_0__
.an-open-is-already-in-progress = Otwieranie jest już w toku.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Nie można ustawić kopii autozapisu w kolejce: zbyt wiele oczekujących otwarć
.jogged-radius = Promień łamany
.none-use-shortcuts-set-key-command =(brak — użyj: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Wyrównanie:
+2
View File
@@ -267,6 +267,8 @@ common =
.autosave-copy-could-not-be-stored = Não foi possível armazenar a cópia de salvamento automático: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" restaurado da cópia de salvamento automático
.autosave-copy-could-not-be-read = Não foi possível ler a cópia de salvamento automático: __ocs_fmt_0__
.an-open-is-already-in-progress = Uma abertura já está em andamento.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Não foi possível enfileirar a cópia de salvamento automático: aberturas pendentes em excesso
.jogged-radius = Raio com desvio
.none-use-shortcuts-set-key-command = (nenhum — use: SHORTCUTS SET <tecla> <comando>)
.alignment-indented-label-title-case = Alinhamento:
+2
View File
@@ -270,6 +270,8 @@ common =
.autosave-copy-could-not-be-stored = Не удалось сохранить копию автосохранения: __ocs_fmt_0__
.restored-from-the-autosave-copy = «__ocs_fmt_0__» восстановлен из копии автосохранения
.autosave-copy-could-not-be-read = Не удалось прочитать копию автосохранения: __ocs_fmt_0__
.an-open-is-already-in-progress = Открытие файла уже выполняется.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Не удалось поставить копию автосохранения в очередь: слишком много ожидающих открытий
.jogged-radius = Радиус с изломом
.none-use-shortcuts-set-key-command = (нет — используйте: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Выравнивание:
+2
View File
@@ -252,6 +252,8 @@ common =
.autosave-copy-could-not-be-stored = Otomatik kayıt kopyası depolanamadı: __ocs_fmt_0__
.restored-from-the-autosave-copy = "__ocs_fmt_0__" otomatik kayıt kopyasından geri yüklendi
.autosave-copy-could-not-be-read = Otomatik kayıt kopyası okunamadı: __ocs_fmt_0__
.an-open-is-already-in-progress = Bir açma işlemi zaten sürüyor.
.autosave-copy-could-not-be-queued-too-many-pending-opens = Otomatik kayıt kopyası sıraya alınamadı: çok fazla bekleyen açma işlemi
.none-use-shortcuts-set-key-command = (none – use: SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = Adapt:
.background-fill-enabled = Arka plan doldurma etkinleştir etkinleştirildi
+2
View File
@@ -254,6 +254,8 @@ common =
.autosave-copy-could-not-be-stored = 无法存储自动保存副本: __ocs_fmt_0__
.restored-from-the-autosave-copy = 已从自动保存副本恢复 "__ocs_fmt_0__"
.autosave-copy-could-not-be-read = 无法读取自动保存副本: __ocs_fmt_0__
.an-open-is-already-in-progress = 已有打开操作正在进行。
.autosave-copy-could-not-be-queued-too-many-pending-opens = 无法排队自动保存副本:待打开的文件过多
.jogged-radius = 折弯半径
.none-use-shortcuts-set-key-command = (无 — 使用:SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = 对齐:
+2
View File
@@ -266,6 +266,8 @@ common =
.autosave-copy-could-not-be-stored = 無法儲存自動儲存副本: __ocs_fmt_0__
.restored-from-the-autosave-copy = 已從自動儲存副本恢復 "__ocs_fmt_0__"
.autosave-copy-could-not-be-read = 無法讀取自動儲存副本: __ocs_fmt_0__
.an-open-is-already-in-progress = 已有開啟作業正在進行中。
.autosave-copy-could-not-be-queued-too-many-pending-opens = 無法將自動儲存副本排入佇列:待開啟的檔案過多
.jogged-radius = 折彎半徑
.none-use-shortcuts-set-key-command = (無 — 使用:SHORTCUTS SET <key> <command>)
.alignment-indented-label-title-case = 對齊:
+5
View File
@@ -2034,6 +2034,11 @@ pub enum Message {
/// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")]
WebAutosaveDiscard,
/// Web: close the restore prompt WITHOUT deleting the offered snapshot
/// (Escape/✕): it is simply re-offered on the next boot. Only the
/// Discard button deletes.
#[cfg(target_arch = "wasm32")]
WebAutosaveDefer,
/// Web: the offered snapshot's bytes are in; queue them for the guarded
/// open poll. The OPFS copy is consumed only once that open succeeds.
#[cfg(target_arch = "wasm32")]
+17
View File
@@ -205,6 +205,23 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
// 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] =
+80 -26
View File
@@ -995,6 +995,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)
@@ -1908,12 +1921,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 saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
@@ -2881,6 +2889,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
@@ -2936,12 +2959,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;
@@ -3081,6 +3099,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
@@ -3101,7 +3134,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) => {
@@ -3128,30 +3173,39 @@ 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));
}
// 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
View File
@@ -846,12 +846,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(
@@ -1022,6 +1025,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)
}
@@ -7121,11 +7126,13 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
return self.update(Message::RecoveryDecline);
}
// 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
+11
View File
@@ -517,6 +517,17 @@ fn append_pdf_page(
page: &PdfPageInput,
fallback_plot_style: Option<&PlotStyleTable>,
) -> Result<(), String> {
// Paper dimensions are validated like the content coordinates below: a
// NaN/inf/zero sheet (`width < MIN` compares false for NaN) would be
// serialized verbatim into a MediaBox no conforming viewer can display,
// and printpdf's own parser would still accept it.
if !page.paper_w.is_finite()
|| !page.paper_h.is_finite()
|| page.paper_w <= 0.0
|| page.paper_h <= 0.0
{
return Err("plot sheet size is not valid".into());
}
let PlotContent {
wires,
hatches,
+93 -26
View File
@@ -83,24 +83,44 @@ pub(crate) fn cap_evictions(
(entries, evicted)
}
/// The marker `recovered_name` appends (and strips when it is already
/// there, so it never stacks across crash cycles).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
const RECOVERED_MARKER: &str = " (recovered)";
/// The name a restored drawing carries as a tab and download: the drawing's
/// stem plus " (recovered)" and its own extension (DWG when the stored name
/// has none, matching how autosave serializes untitled tabs). The extension
/// must survive intact because the web parser picks the format from it.
/// stem plus " (recovered)" and its own extension. The extension split
/// follows the WORKER convention (`name.rsplit('.').next()`, the whole name
/// when there is no dot): a dot-file like ".dxf" is a DXF name to the parse
/// worker, and `Path::extension` would misread it as extensionless —
/// restoring DXF bytes under a ".dwg" name jams the restore door. A name
/// with no extension at all defaults to DXF, matching how autosave
/// serializes untitled tabs (`source_is_dxf` → `dwg_source_version.is_none()`).
/// An existing trailing marker is stripped first, so "(recovered)" stacks
/// at most once no matter how many crash cycles re-snapshotted a previously
/// recovered name.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn recovered_name(drawing: &str) -> String {
let path = std::path::Path::new(drawing);
let stem = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| drawing.to_string());
let ext = path
.extension()
.map(|ext| ext.to_string_lossy().into_owned())
.filter(|ext| !ext.is_empty())
.unwrap_or_else(|| "dwg".to_string());
format!("{stem} (recovered).{ext}")
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
};
// Idempotent: strip every trailing marker, then append exactly one.
while let Some(stripped) = stem.strip_suffix(RECOVERED_MARKER) {
stem = stripped;
}
format!("{stem}{RECOVERED_MARKER}.{ext}")
}
/// Overwrite (or create) the snapshot for `name`. The optional thumbnail is
@@ -108,7 +128,7 @@ pub(crate) fn recovered_name(drawing: &str) -> String {
#[cfg(target_arch = "wasm32")]
pub async fn snapshot(name: &str, bytes: &[u8], thumbnail: Option<&[u8]>) -> Result<(), String> {
let directory = autosave_directory(true).await?;
let mut names = read_index(&directory).await;
let mut names = read_index(&directory).await?;
if !names.iter().any(|stored| stored == name) {
names.push(name.to_string());
write_index(&directory, &names).await?;
@@ -140,7 +160,9 @@ pub async fn remove(name: &str) -> Result<(), String> {
.await
.map_err(super::web_recent::js_error);
let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await;
let mut names = read_index(&directory).await;
// A corrupt index must abort the rewrite (not reset it to `names`),
// or this remove would orphan every other row's snapshot.
let mut names = read_index(&directory).await?;
let before = names.len();
names.retain(|stored| stored != name);
if names.len() != before {
@@ -161,7 +183,9 @@ pub async fn scan() -> Result<Vec<WebAutosaveEntry>, String> {
let directory = autosave_directory(false).await?;
let mut entries = Vec::new();
let mut dead: Vec<String> = Vec::new();
for name in read_index(&directory).await {
// An unreadable index fails the whole boot scan: guessing an empty list
// would cap-evict and remove snapshots the index can no longer name.
for name in read_index(&directory).await? {
let file = match super::web_recent::get_file(&directory, &snapshot_key(&name)).await {
Ok(file) => file,
// Crash between the index and snapshot writes: the row points
@@ -212,12 +236,23 @@ fn thumbnail_key(name: &str) -> String {
}
#[cfg(target_arch = "wasm32")]
async fn read_index(directory: &web_sys::FileSystemDirectoryHandle) -> Vec<String> {
super::web_recent::read_entry(directory, INDEX_KEY)
.await
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
async fn read_index(
directory: &web_sys::FileSystemDirectoryHandle,
) -> Result<Vec<String>, String> {
let bytes = match super::web_recent::read_entry(directory, INDEX_KEY).await {
Ok(bytes) => bytes,
// A missing index is a fresh store.
Err(_) => return Ok(Vec::new()),
};
// An empty (freshly created or truncated) index is an empty store.
if bytes.is_empty() {
return Ok(Vec::new());
}
// A non-empty file that fails to parse is real data we cannot name:
// returning it as empty would let the next write reset the index and
// silently orphan every other row's snapshot. Surface the failure so
// callers skip the round/boot and leave the file untouched.
serde_json::from_slice(&bytes).map_err(|error| error.to_string())
}
#[cfg(target_arch = "wasm32")]
@@ -266,10 +301,42 @@ mod tests {
fn recovered_names_keep_a_parseable_extension() {
assert_eq!(recovered_name("plan.dwg"), "plan (recovered).dwg");
assert_eq!(recovered_name("sketch.dxf"), "sketch (recovered).dxf");
assert_eq!(recovered_name("Drawing1"), "Drawing1 (recovered).dwg");
// Extensionless names (untitled tabs) restore as DXF: that is what
// autosave serializes them as (`source_is_dxf` → no DWG source
// version), and the parse worker dispatches on the final extension.
assert_eq!(recovered_name("Drawing1"), "Drawing1 (recovered).dxf");
// Multi-dot names keep everything but the final extension in the stem.
assert_eq!(recovered_name("a.b.dwg"), "a.b (recovered).dwg");
assert_eq!(recovered_name(""), " (recovered).dwg");
// A dot-file like ".dxf" is a DXF name under the worker's
// `rsplit('.')` convention — the extension must not flip to dwg.
assert_eq!(recovered_name(".dxf"), ".dxf (recovered).dxf");
assert_eq!(recovered_name(".dwg"), ".dwg (recovered).dwg");
assert_eq!(recovered_name(""), " (recovered).dxf");
}
#[test]
fn the_recovered_marker_never_stacks() {
// Restoring sets the tab name to the recovered name; a later
// autosave round snapshots under it. One marker per drawing:
assert_eq!(
recovered_name("plan (recovered).dwg"),
"plan (recovered).dwg"
);
// Idempotent even for names that already stacked (old cycles).
assert_eq!(
recovered_name("plan (recovered) (recovered).dwg"),
"plan (recovered).dwg"
);
// Extensionless stacked names de-stack too, then gain ".dxf".
assert_eq!(
recovered_name("plan (recovered)"),
"plan (recovered).dxf"
);
// A marker embedded in the middle (not a suffix) is left alone.
assert_eq!(
recovered_name("(recovered) plan.dwg"),
"(recovered) plan (recovered).dwg"
);
}
#[test]
+29 -14
View File
@@ -12,7 +12,8 @@ use wasm_bindgen_futures::JsFuture;
/// A stray multi-drop must not grow the queue without bound; extras are
/// rejected with a console warning instead of being silently dropped.
const QUEUE_CAP: usize = 8;
/// Entries are just name+bytes; 32 drawings is a realistic folder drop.
const QUEUE_CAP: usize = 32;
/// One queued open. `restore_key` tags an autosave-restore file so its
/// snapshot is deleted only when THIS open succeeds — matching by identity,
@@ -41,7 +42,9 @@ fn lock_queue() -> std::sync::MutexGuard<'static, VecDeque<QueuedOpen>> {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn push(entry: QueuedOpen) {
/// Returns `false` (after a console warning) when the queue is full and the
/// entry was NOT stored — the caller decides how to surface the loss.
fn push(entry: QueuedOpen) -> bool {
let name = entry.name.clone();
let mut queue = lock_queue();
if queue.len() >= QUEUE_CAP {
@@ -49,35 +52,40 @@ fn push(entry: QueuedOpen) {
warn(&format!(
"ocs: dropped-file queue full; ignoring \"{name}\""
));
return;
return false;
}
queue.push_back(entry);
true
}
/// Queue already-read drawing bytes (drag-and-drop) for the 50 ms poll to
/// open through the guarded web-picker flow.
pub fn enqueue_open(name: String, bytes: Vec<u8>) {
/// open through the guarded web-picker flow. Returns `false` when the queue
/// is full and the bytes were dropped.
pub fn enqueue_open(name: String, bytes: Vec<u8>) -> bool {
push(QueuedOpen {
name,
bytes,
restore_key: None,
});
})
}
/// Queue an autosave-restore file tagged with its snapshot key.
pub fn enqueue_restore(name: String, bytes: Vec<u8>, restore_key: String) {
/// Queue an autosave-restore file tagged with its snapshot key. Returns
/// `false` when the queue is full and the bytes were dropped (the OPFS
/// snapshot survives; the caller must not wait for an open that will
/// never start).
pub fn enqueue_restore(name: String, bytes: Vec<u8>, restore_key: String) -> bool {
push(QueuedOpen {
name,
bytes,
restore_key: Some(restore_key),
});
})
}
/// JS-callable entry (same shape as `ocs_control_submit`) for hosts that
/// forward drops themselves.
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn ocs_web_drop_file(name: String, bytes: js_sys::Uint8Array) {
enqueue_open(name, bytes.to_vec());
let _ = enqueue_open(name, bytes.to_vec());
}
/// Pop the oldest dropped file that the poll has not started opening yet.
@@ -179,7 +187,10 @@ pub fn install() {
for (name, read) in reads {
match JsFuture::from(read).await {
Ok(buffer) => {
enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec())
// A full queue warns on the console; the drop
// handler has no command line to talk to.
let _ =
enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec());
}
Err(error) => {
web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!(
@@ -203,7 +214,11 @@ pub fn install() {
}
fn is_drawing_name(name: &str) -> bool {
name.rsplit_once('.').is_some_and(|(_, extension)| {
matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf")
})
// Same convention as the worker's reader dispatch (`rsplit('.').next()`):
// a dot-less name is its own extension, so a file literally named
// "DWG"/"dxf" parses and must not be filtered out here.
matches!(
name.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(),
"dwg" | "dxf"
)
}
+2
View File
@@ -4608,6 +4608,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Autosave copy could not be stored: {error}" => Some(("common", "autosave-copy-could-not-be-stored")),
"Restored \"{}\" from the autosave copy" => Some(("common", "restored-from-the-autosave-copy")),
"Autosave copy could not be read: {error}" => Some(("common", "autosave-copy-could-not-be-read")),
"An open is already in progress." => Some(("common", "an-open-is-already-in-progress")),
"Autosave copy could not be queued: too many pending opens" => Some(("common", "autosave-copy-could-not-be-queued-too-many-pending-opens")),
_ => None,
}
}
+29 -5
View File
@@ -385,9 +385,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();
@@ -399,6 +401,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.
@@ -501,13 +508,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();
}
@@ -543,6 +553,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"))]
+297
View File
@@ -0,0 +1,297 @@
//! RED-TEAM: the Model-branch sheet floor in `layout_plot_page_for`
//! (src/app/update/file.rs:3765-3800, `MIN_MODEL_SHEET_MM = 10.0`) attacked
//! through its REAL input: `Scene::model_space_extents`
//! (src/scene/mod.rs:10925 `compute_model_space_extents`).
//!
//! The App method is `pub(super)` and unconstructible outside the app, so the
//! floor arithmetic is replicated verbatim below and fed the REAL extents of
//! hostile scenes (NaN / ±inf / f64-overflow / reversed-header / huge /
//! exactly-10mm / 9.99mm drawings). The question: can a NaN or non-finite
//! page ever reach `pdf_export`, where `width < MIN` silently compares false
//! for NaN and the "normal" branch would emit a NaN-sized `Mm(paper_w)`?
#![cfg(not(target_arch = "wasm32"))]
use acadrust::entities::Line;
use acadrust::types::Vector3;
use acadrust::EntityType;
use OpenCADStudio::scene::Scene;
fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> EntityType {
let mut l = Line::new();
l.start = Vector3::new(x1, y1, 0.0);
l.end = Vector3::new(x2, y2, 0.0);
EntityType::Line(l)
}
/// Verbatim copy of the Model-branch sheet arithmetic
/// (src/app/update/file.rs:3765-3800), reduced to its inputs:
/// `model_space_extents()` + the dialog sheet (default A4 landscape 297x210).
const MIN_MODEL_SHEET_MM: f64 = 10.0;
fn model_sheet(extents: Option<(f64, f64, f64, f64, f64, f64)>) -> (f64, f64, f64, f64) {
let selected_sheet: (f64, f64) = (297.0, 210.0);
let standard_sheet = {
let (w, h) = selected_sheet;
(w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h))
};
if let Some((min_x, min_y, _, max_x, max_y, _)) = extents {
let margin = 1.05_f64;
let width = ((max_x - min_x) as f64 * margin).max(1.0);
let height = ((max_y - min_y) as f64 * margin).max(1.0);
if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM {
let (w, h) = standard_sheet.unwrap_or((297.0, 210.0));
let page_w = width.max(w);
let page_h = height.max(h);
(
page_w,
page_h,
(page_w - width) * 0.5 - min_x as f64,
(page_h - height) * 0.5 - min_y as f64,
)
} else {
let pad_x = (width - (max_x - min_x) as f64) * 0.5;
let pad_y = (height - (max_y - min_y) as f64) * 0.5;
(width, height, -(min_x as f64) + pad_x, -(min_y as f64) + pad_y)
}
} else {
(297.0, 210.0, 0.0, 0.0)
}
}
fn flat_extents(scene: &Scene) -> Option<(f64, f64, f64, f64, f64, f64)> {
scene
.model_space_extents()
.map(|(min, max)| (min.x as f64, min.y as f64, min.z as f64, max.x as f64, max.y as f64, max.z as f64))
}
/// Entities with NaN coordinates must not poison the sheet: the extents pass
/// in `compute_model_space_extents` drops non-finite vertices
/// (src/scene/mod.rs:10945 `(x as f32).is_finite()`), so NaN alone yields
/// `None` (default 297x210 page) and NaN mixed with real geometry yields the
/// finite bounds only. The `width < MIN` NaN trap in the floor branch is
/// unreachable through this input.
#[test]
fn nan_entities_cannot_produce_a_nan_or_floored_wrongly_page() {
// Only-NaN drawing: no usable extents at all.
let mut scene = Scene::new();
scene.add_entity(line(f64::NAN, f64::NAN, f64::NAN, f64::NAN));
scene.add_entity(line(0.0, f64::NAN, 10.0, f64::NAN));
let extents = flat_extents(&scene);
assert!(
extents.is_none(),
"NaN-only geometry must yield None, got {extents:?}"
);
let (w, h, ox, oy) = model_sheet(extents);
assert_eq!((w, h), (297.0, 210.0));
assert_eq!((ox, oy), (0.0, 0.0));
// NaN contaminating an otherwise finite drawing.
let mut scene = Scene::new();
scene.add_entity(line(0.0, 0.0, 10.0, 10.0));
scene.add_entity(line(f64::NAN, 5.0, f64::NAN, 5.0));
let (w, h, ox, oy) = model_sheet(flat_extents(&scene));
assert_eq!((w, h), (10.5, 10.5), "10mm * 1.05 margin, NaN entity ignored");
assert!(w.is_finite() && h.is_finite() && ox.is_finite() && oy.is_finite());
// Every scene fed to the floor keeps the invariant the branch promises:
// a real, finite page of at least MIN on both axes.
let mut hostile = vec![
Scene::new(), // empty drawing
];
let mut inf_scene = Scene::new();
inf_scene.add_entity(line(f64::NEG_INFINITY, 0.0, f64::INFINITY, f64::INFINITY));
hostile.push(inf_scene);
let mut overflow_scene = Scene::new(); // finite f64, overflows f32
overflow_scene.add_entity(line(-1e300, -1e300, 1e300, 1e300));
hostile.push(overflow_scene);
let mut huge_f32_scene = Scene::new(); // finite even after the f32 cast
huge_f32_scene.add_entity(line(-3.0e38, -3.0e38, 3.0e38, 3.0e38));
hostile.push(huge_f32_scene);
for scene in &hostile {
let extents = flat_extents(scene);
if let Some((mnx, mny, _, mxx, mxy, _)) = extents {
assert!(
mnx.is_finite() && mny.is_finite() && mxx.is_finite() && mxy.is_finite(),
"extents must be finite: {extents:?}"
);
assert!(mnx <= mxx && mny <= mxy, "min/max must be ordered: {extents:?}");
}
let (w, h, ox, oy) = model_sheet(extents);
assert!(w.is_finite() && h.is_finite() && ox.is_finite() && oy.is_finite());
assert!(w >= MIN_MODEL_SHEET_MM && h >= MIN_MODEL_SHEET_MM, "({w}, {h})");
}
}
/// A drawing whose only coordinates exceed f32 range (e.g. 1e300) is silently
/// dropped from the extents (the `(x as f32).is_finite()` filter) — the plot
/// falls back to the default sheet, so the drawing PLOTS EMPTY rather than
/// erroring. Behavior note, not a crash.
#[test]
fn beyond_f32_range_geometry_is_silently_ignored_by_extents() {
let mut scene = Scene::new();
scene.add_entity(line(1e300, 1e300, 2e300, 2e300));
assert!(
flat_extents(&scene).is_none(),
"1e300 coordinates overflow the f32 wire cache and vanish from extents"
);
// While geometry at f32::MAX-ish scale stays (absurd page, but finite).
let mut scene = Scene::new();
scene.add_entity(line(-3.0e38, 0.0, 3.0e38, 3.0e38));
let (w, h, _, _) = model_sheet(flat_extents(&scene));
assert!(w > 1.0e38 && h > 1.0e38, "({w}, {h})");
}
/// The reversed-extents case (min > max) cannot come from
/// `compute_model_space_extents` (min starts at +inf and only decreases) and
/// the EXTMIN/EXTMAX header fallback rejects it with a strict `<`
/// (src/scene/mod.rs:11005). A fresh document's header is reversed by
/// DEFAULT (1e20 / -1e20 sentinels) and its model-space block record exists,
/// so an empty drawing exercises the header path immediately.
#[test]
fn reversed_extents_are_unreachable_even_from_a_hostile_header() {
// Real parsed document (has a *Model_Space block), all entities removed:
// the header fallback is the only extents source left. One fresh scene
// per case — `model_space_extents` caches by geometry_epoch, and header
// mutation alone does not bump the epoch.
let empty_scene = || {
let mut scene = Scene::new();
scene.document = OpenCADStudio::io::load_bytes(
"embedded-image-print.dxf",
include_bytes!("fixtures/embedded-image-print.dxf").to_vec(),
)
.expect("fixture");
let handles: Vec<_> = scene.document.entities().map(|e| e.common().handle).collect();
for handle in handles {
scene.document.remove_entity(handle);
}
scene
};
// Reversed header: rejected.
let mut scene = empty_scene();
scene.document.header.model_space_extents_min = Vector3::new(100.0, 100.0, 0.0);
scene.document.header.model_space_extents_max = Vector3::new(-100.0, -100.0, 0.0);
assert!(
flat_extents(&scene).is_none(),
"reversed EXTMIN/EXTMAX must be rejected, not negated into a page"
);
// NaN header: also rejected by the strict `<` (NaN comparisons are false).
let mut scene = empty_scene();
scene.document.header.model_space_extents_min = Vector3::new(f64::NAN, f64::NAN, 0.0);
scene.document.header.model_space_extents_max = Vector3::new(10.0, 10.0, 0.0);
assert!(flat_extents(&scene).is_none(), "NaN EXTMIN must be rejected");
// Ordered, sane header alone (no drawable geometry) feeds the plot
// through the fallback path.
let mut scene = empty_scene();
scene.document.header.model_space_extents_min = Vector3::new(-100.0, -100.0, 0.0);
scene.document.header.model_space_extents_max = Vector3::new(100.0, 100.0, 0.0);
let extents = flat_extents(&scene)
.expect("ordered sane header feeds the fallback");
assert_eq!((extents.0, extents.1, extents.3, extents.4), (-100.0, -100.0, 100.0, 100.0));
let (w, h, ox, oy) = model_sheet(Some(extents));
// 200mm extents * 1.05 = 210mm page (normal branch, above the floor);
// offset = -min + (210 - 200)/2 margin padding = 100 + 5.
assert_eq!((w, h), (210.0, 210.0));
assert!((ox - 105.0).abs() < 1e-9 && (oy - 105.0).abs() < 1e-9);
}
/// The floor boundary: the floor compares the MARGIN-INCLUDED size
/// (`extents * 1.05`), not the raw extents, so "exactly 10.0mm" and even
/// 9.99mm drawings take the NORMAL branch (page = 10.5mm and 10.4895mm
/// respectively) — the floor only catches extents below 10/1.05 ≈ 9.524mm.
/// A 9.5mm x 10000mm profile IS floored, per-axis: 297mm wide, 10500mm tall,
/// with the content centered by the offsets.
#[test]
fn floor_boundary_math_per_axis() {
// Exactly 10.0mm extents: normal branch (10 * 1.05 = 10.5 >= 10).
let (w, h, _, _) = model_sheet(Some((0.0, 0.0, 0.0, 10.0, 10.0, 0.0)));
assert_eq!((w, h), (10.5, 10.5));
// 9.99 x 10000: STILL the normal branch (9.99 * 1.05 = 10.4895 >= 10) —
// the task's guess "(297, 10000+5%)" is off by the margin.
let (w, h, _, _) = model_sheet(Some((0.0, 0.0, 0.0, 9.99, 10000.0, 0.0)));
assert!((w - 10.4895).abs() < 1e-9);
assert!((h - 10500.0).abs() < 1e-9);
// 9.5 x 10000: floored per-axis — thin axis lifted to the standard sheet
// width (297), long axis keeps its margin size, content centered.
let (w, h, ox, oy) = model_sheet(Some((0.0, 0.0, 0.0, 9.5, 10000.0, 0.0)));
assert_eq!(w, 297.0);
assert!((h - 10500.0).abs() < 1e-9);
// width = 9.5 * 1.05 = 9.975 → offset_x = (297 - 9.975)/2 - 0 = 143.5125
assert!((ox - (297.0 - 9.975) * 0.5).abs() < 1e-9);
// height = 10500 exactly fills the page → offset_y = -min_y = 0.
assert!((oy - 0.0).abs() < 1e-9);
// A dot (zero extents): width clamped to 1.0 by `.max(1.0)`, floored to
// the full standard sheet, offsets center the dot's point.
let (w, h, ox, oy) = model_sheet(Some((5.0, 7.0, 0.0, 5.0, 7.0, 0.0)));
assert_eq!((w, h), (297.0, 210.0));
assert!((ox - ((297.0 - 1.0) * 0.5 - 5.0)).abs() < 1e-9);
assert!((oy - ((210.0 - 1.0) * 0.5 - 7.0)).abs() < 1e-9);
}
/// Defense-in-depth check at the pdf_export layer: if a NaN page size ever
/// DID reach it (future regression, or a non-Model branch), what happens?
/// `PdfPageInput.paper_w = NaN` used to be serialized verbatim into a
/// MediaBox containing the literal string "nan" — a nominally-valid PDF no
/// conforming viewer can display (and which printpdf's own parser even
/// accepts). The paper fields are now validated like the content
/// coordinates (src/io/pdf_export.rs `append_pdf_page`): non-finite or
/// non-positive dimensions are rejected with a plain-English error.
#[test]
fn pdf_export_layer_faced_with_a_nan_page_size() {
use OpenCADStudio::io::pdf_export::{export_pdf, PdfPageInput, PdfPlotOptions};
let path = std::env::temp_dir().join(format!(
"ocs-redteam-nan-page-{}.pdf",
std::process::id()
));
for (paper_w, paper_h) in [
(f64::NAN, f64::NAN),
(f64::NAN, 297.0),
(210.0, f64::INFINITY),
(f64::NEG_INFINITY, 297.0),
(0.0, 297.0),
(210.0, 0.0),
(-210.0, 297.0),
] {
let page = PdfPageInput {
content: Default::default(),
paper_w,
paper_h,
offset_x: 0.0,
offset_y: 0.0,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: PdfPlotOptions::default(),
plot_style: None,
};
let error = export_pdf(&page, &path)
.expect_err("non-finite/zero sheet sizes must be rejected, not plotted");
assert!(
error.contains("plot sheet size is not valid"),
"page ({paper_w}, {paper_h}): {error}"
);
assert!(
!path.exists(),
"no artifact may be written for a rejected sheet"
);
}
// A sane sheet still exports normally.
let page = PdfPageInput {
content: Default::default(),
paper_w: 210.0,
paper_h: 297.0,
offset_x: 0.0,
offset_y: 0.0,
rotation_deg: 0,
scale: 1.0,
clip: None,
options: PdfPlotOptions::default(),
plot_style: None,
};
export_pdf(&page, &path).expect("sane sheet exports");
assert!(path.exists());
let bytes = std::fs::read(&path).unwrap();
assert!(
!String::from_utf8_lossy(&bytes).to_lowercase().contains("nan"),
"no NaN may leak into a written page"
);
let _ = std::fs::remove_file(&path);
}
+166
View File
@@ -0,0 +1,166 @@
//! RED-TEAM: the web drag&drop name filter (`src/io/web_drop.rs`
//! `is_drawing_name`) cross-checked against the parse-worker extension gate
//! (`crates/ocs_web_worker/src/lib.rs:44-52`) it is supposed to pre-empt.
//!
//! A drop that passes the filter but fails the worker is a user-visible dead
//! end (file read, queued, then a parse error); a drop that fails the filter
//! for bytes the worker COULD parse is a silently ignored file. The two gates
//! are one-liners (`pub(crate)`/inline, invisible to integration tests), so
//! they are replicated VERBATIM here and fuzzed against each other; verdicts
//! are cross-checked with the real native loader `OpenCADStudio::io::load_bytes`
//! (src/io/mod.rs:722), which derives the format from the name exactly like
//! the worker.
#![cfg(not(target_arch = "wasm32"))]
const FIXTURE_DXF: &[u8] = include_bytes!("fixtures/embedded-image-print.dxf");
/// Verbatim copy of src/io/web_drop.rs (is_drawing_name).
fn is_drawing_name(name: &str) -> bool {
matches!(
name.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(),
"dwg" | "dxf"
)
}
/// Verbatim copy of the worker's gate (crates/ocs_web_worker/src/lib.rs:44-52).
fn worker_accepts(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase();
matches!(ext.as_str(), "dwg" | "dxf")
}
/// REGRESSION (fixed): the drop filter and the worker gate now share the
/// `rsplit('.').next()` convention, so a file literally named "DWG"/"dxf"
/// (no dot) is accepted by BOTH — previously the filter silently ignored
/// files the parser behind it would have opened.
#[test]
fn extensionless_dwg_named_files_are_dropped_but_parseable() {
for name in ["DWG", "dwg", "Dwg", "DXF", "dxf"] {
assert!(
is_drawing_name(name),
"{name:?}: drop filter accepts (worker convention)"
);
assert!(worker_accepts(name), "{name:?}: worker gate accepts");
// The native loader shares the worker's convention: same bytes+name
// it would reach the DXF/DWG reader (fixture is DXF, so the dxf
// spellings truly parse; dwg spellings reach the DWG reader).
let result = OpenCADStudio::io::load_bytes(name, FIXTURE_DXF.to_vec());
assert_eq!(
result.is_ok(),
name.to_ascii_lowercase() == "dxf",
"{name:?}: format is picked from the dot-less name itself"
);
}
}
/// For every name CONTAINING a dot the two gates agree: no dead end (filter
/// pass + worker refuse) and no silently ignored parseable file (filter
/// refuse + worker accept). Attacked with the hostile matrix.
#[test]
fn drop_filter_and_worker_gate_agree_on_dotted_names() {
let hostile = [
"plan.dwg", // baseline
".DWG", // uppercase dot-file: accepted by BOTH
".dwg", // dot-file
"x.dwg ", // trailing space (legal on Linux)
"x.dwg\u{a0}", // trailing non-breaking space
"x .dwg", // space before extension
"x.dwgx", // near-miss extension
"x.dwg.tar", // double extension
"x.DwF", // Design Web Format: rejected
"x.DXF", // uppercase: accepted
"x.dxf.", // trailing dot: rejected
".", // only a dot
"..", // only dots
"…", // only an ellipsis char (not a dot)
"", // empty (no dot — covered by the other test)
"x.dωg", // Greek omega lookalike
"x.dWg", // fullwidth W (U+FF37)
"x.dwg\n", // trailing newline
"\nx.dwg", // leading newline
"x.d\u{0130}f", // dotted capital I (full lowercase => 2 chars)
"x.dx\u{212a}", // KELVIN SIGN lowercases to 'k'
"con.dwg", // Windows reserved stem
"a.b.dxf", // multi-dot
"plán.dwg", // unicode stem
"図面.dxf", // CJK stem
"plan.sv$", // native autosave copy: rejected on web
"plan.bak", // native backup: rejected on web
];
for name in hostile {
assert_eq!(
is_drawing_name(name),
worker_accepts(name),
"drop filter and worker gate disagree on {name:?}"
);
}
// Spot-check the interesting verdicts against the REAL loader (the
// native `load_bytes` shares the rsplit('.') convention).
assert!(is_drawing_name(".DWG") && worker_accepts(".DWG"));
assert!(
OpenCADStudio::io::load_bytes(".DWG", FIXTURE_DXF.to_vec()).is_err(),
"dot-file '.DWG' is gated IN by both, then parsed as DWG (bytes decide)"
);
assert!(!is_drawing_name("x.dwg ") && !worker_accepts("x.dwg "));
let err = OpenCADStudio::io::load_bytes("x.dwg ", FIXTURE_DXF.to_vec()).unwrap_err();
assert!(err.contains("Unsupported file format"), "{err}");
// No unicode fold can turn a non-ASCII extension into "dwg"/"dxf":
// the only single-char case-folds onto ASCII letters are ASCII
// themselves (plus KELVIN SIGN -> k, not in the set), so ascii-vs-full
// lowercase cannot diverge for these two literals.
assert!(!is_drawing_name("x.dx\u{212a}"));
assert!(!worker_accepts("x.dx\u{212a}"));
}
/// Web/native divergence, real-code proof: `.sv$` and `.bak` files open
/// NATIVELY (sniffed, src/io/mod.rs:723) but are refused by BOTH web gates —
/// a user switching a crash-recovered `.sv$` from desktop to web gets a
/// console warning and nothing else.
#[test]
fn sv_dollar_and_bak_open_natively_but_are_refused_on_web() {
assert!(!is_drawing_name("plan.sv$"));
assert!(!worker_accepts("plan.sv$"));
assert!(!is_drawing_name("plan.bak"));
assert!(!worker_accepts("plan.bak"));
// The native loader sniffs the same bytes under the same name.
assert!(OpenCADStudio::io::load_bytes("plan.sv$", FIXTURE_DXF.to_vec()).is_ok());
assert!(OpenCADStudio::io::load_bytes("plan.bak", FIXTURE_DXF.to_vec()).is_ok());
// ...and refuses the web-refused near-misses identically.
assert!(OpenCADStudio::io::load_bytes("plan.dwgx", FIXTURE_DXF.to_vec()).is_err());
}
/// The filter is name-only: a `.dwg`-named file whose bytes are DXF passes
/// both gates and dies in the DWG reader — the dead end the filter cannot
/// prevent (and cannot be expected to).
#[test]
fn filter_cannot_catch_bytes_format_mismatch() {
assert!(is_drawing_name("actually-dxf.dwg"));
assert!(worker_accepts("actually-dxf.dwg"));
let error = OpenCADStudio::io::load_bytes("actually-dxf.dwg", FIXTURE_DXF.to_vec())
.expect_err("DWG reader rejects DXF bytes");
assert!(!error.contains("Unsupported file format"), "{error}");
}
/// Fuzz: over every short ASCII name, the ONLY divergences are the dot-less
/// exact "dwg"/"dxf" spellings pinned above — no other mismatch exists.
#[test]
fn exhaustive_short_name_agreement_except_extensionless_spelling() {
let alphabet: Vec<u8> = (b'.'..=b'z').collect();
let mut buffer = [0u8; 3];
for &a in &alphabet {
for &b in &alphabet {
for &c in &alphabet {
buffer = [a, b, c];
let name = std::str::from_utf8(&buffer).unwrap();
let filter = is_drawing_name(name);
let worker = worker_accepts(name);
if filter != worker {
// The one known class: dot-less exact spelling.
assert!(
!name.contains('.') && worker,
"unexpected divergence on {name:?}"
);
}
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! RED-TEAM: the web export filename pipeline
//! `pick_stl_path_owned`/`pick_step_path_owned` (src/io/stl.rs:102-104,
//! src/io/step.rs:258-260, wasm-only one-liners) fed by
//! `tab.current_path.file_stem()` (src/app/update/mod.rs:1310-1315) and
//! consumed by `sys::download_bytes` via `path.file_name()`
//! (src/app/update/file.rs:1717-1721, src/sys.rs:348-372).
//!
//! The wasm functions are cfg'd out on native, so the exact composition is
//! replicated here (it is pure `Path` semantics + `format!`) and attacked
//! with hostile current_paths. `download_bytes` itself only ever passes the
//! string to `a.set_download` — a plain JS property set that cannot panic in
//! Rust; browsers sanitize separators/control characters in the attribute.
#![cfg(not(target_arch = "wasm32"))]
/// Verbatim composition of the web flow: stem extraction (mod.rs:1310-1315)
/// -> virtual path (stl.rs:103 / step.rs:259) -> download name
/// (file.rs:1717-1721).
fn stl_download_name(current_path: Option<&std::path::Path>) -> String {
let stem = current_path
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let path = std::path::PathBuf::from(format!("{stem}.stl"));
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "export.stl".into())
}
fn step_download_name(current_path: Option<&std::path::Path>) -> String {
let stem = current_path
.and_then(|p| p.file_stem())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let path = std::path::PathBuf::from(format!("{stem}.step"));
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "export.step".into())
}
/// Path separators coming from a hostile display name never survive into the
/// download: `file_stem()` already drops the directory part, and the
/// re-parsed virtual path's `file_name()` drops it again. No matter what
/// "current_path" a host page injects (the JS entry `ocs_web_drop_file`
/// accepts arbitrary name strings), the download filename cannot traverse.
#[test]
fn separators_and_dotdots_never_reach_the_download_name() {
// Host page drops "a/b.dwg" — name with a forward separator.
let name = stl_download_name(Some(std::path::Path::new("a/b.dwg")));
assert_eq!(name, "b.stl");
// Windows-style separator (web targets unix path rules: not a separator
// there, but `file_name()` strips any leading "./" etc.; backslashes
// survive as literal characters — browsers strip them in the download
// attribute, and they cannot traverse OPFS because the name is never
// used as a path, only as a display string + FNV-hash key input).
let name = stl_download_name(Some(std::path::Path::new("..\\evil.dwg")));
#[cfg(windows)]
assert_eq!(name, "evil.stl");
#[cfg(not(windows))]
assert_eq!(name, "..\\evil.stl");
// Pure ".." cannot be a stem: file_stem() of ".." is None -> "drawing".
assert_eq!(stl_download_name(Some(std::path::Path::new(".."))), "drawing.stl");
assert_eq!(step_download_name(Some(std::path::Path::new(".."))), "drawing.step");
// No path at all (untitled tab).
assert_eq!(stl_download_name(None), "drawing.stl");
assert_eq!(step_download_name(None), "drawing.step");
// Extension-less drawing name.
assert_eq!(stl_download_name(Some(std::path::Path::new("Drawing1"))), "Drawing1.stl");
}
/// Quotes, newlines, control characters and unicode ride through as inert
/// string bytes: the Rust side does no filesystem or shell operation with
/// them (`download_bytes` builds a Blob and sets the `download` attribute —
/// src/sys.rs:356-369 — which cannot panic). NOTE: even an interior NUL
/// survives the whole Rust pipeline (std::path only rejects it at OS
/// boundaries, and there are none on web) — JS strings legally contain NUL,
/// and the browser sanitizes it in the download attribute. No injection, no
/// panic — verified by construction of the pipeline.
#[test]
fn quote_and_control_char_stems_stay_inert_strings() {
for hostile in [
"ev\"il", "ev'il", "line\nbreak", "tab\there", "cr\r lf", "nul\u{0}ish",
"café", "図面", "..\\..\\evil", "con", "..",
] {
let path = std::path::PathBuf::from(format!("{hostile}.dwg"));
let name = stl_download_name(Some(&path));
assert!(name.ends_with(".stl"), "{name:?}");
let step_name = step_download_name(Some(&path));
assert!(step_name.ends_with(".step"), "{step_name:?}");
}
// Pin the surprising-but-harmless case: an interior NUL is NOT stripped
// anywhere in Rust; it would reach `set_download` as a JS string
// containing U+0000 (legal), and the browser scrubs it.
let name = stl_download_name(Some(std::path::Path::new("nul\u{0}ish.dwg")));
assert_eq!(name, "nul\u{0}ish.stl");
}
+134
View File
@@ -0,0 +1,134 @@
//! RED-TEAM: the FNV-1a name-hash keying shared by `web_recent` and
//! `web_autosave` (`src/io/web_recent.rs:225-242`, `pub(crate)` — replicated
//! verbatim below; OPFS itself is browser-only).
//!
//! Questions attacked:
//! 1. Does the code rely on hash uniqueness? (Reading: YES — a collision
//! silently merges two drawings' OPFS entries; see test docs below.)
//! 2. Unicode normalization: NFC vs NFD "café.dwg" hash differently.
//! 3. Are real collisions findable? (Pollard-rho result pinned below.)
#![cfg(not(target_arch = "wasm32"))]
/// Verbatim copy of src/io/web_recent.rs:235-242.
fn name_hash(name: &str) -> String {
let mut hash = 0xcbf29ce484222325_u64;
for byte in name.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
/// Verbatim composition of the OPFS entry names both stores derive from it
/// (web_recent.rs:225-231, web_autosave.rs:205-211).
fn cache_key(name: &str) -> String {
format!("{}.cad", name_hash(name))
}
fn thumbnail_key(name: &str) -> String {
format!("{}.thumb", name_hash(name))
}
fn snapshot_key(name: &str) -> String {
format!("{}.sv$", name_hash(name))
}
/// The implementation is exactly canonical FNV-1a 64 (offset basis, then
/// xor-byte/multiply per byte) — pinned against the reference vectors so the
/// collision analysis below applies to the shipped code.
#[test]
fn the_hash_is_canonical_fnv1a_64() {
assert_eq!(name_hash(""), format!("{:016x}", 0xcbf29ce484222325_u64));
assert_eq!(name_hash("a"), format!("{:016x}", 0xaf63dc4c8601ec8c_u64));
assert_eq!(name_hash("foobar"), format!("{:016x}", 0x85944171f73967e8_u64));
// Keys are bare hex + fixed suffixes: they can never contain path
// separators or grow with the drawing name, whatever the name contains.
for hostile in [
"../evil.dwg",
"..\\evil.dxf",
"a/b/c.dwg",
"nul\u{0}.dwg",
"plan.sv$",
"",
"x".repeat(4096).as_str(),
] {
let key = snapshot_key(hostile);
let (prefix, suffix) = key.split_at(16);
assert!(prefix.chars().all(|c| c.is_ascii_hexdigit()), "{key:?}");
assert_eq!(suffix, ".sv$");
assert_eq!(cache_key(hostile).len(), ".cad".len() + 16);
assert_eq!(thumbnail_key(hostile).len(), ".thumb".len() + 16);
}
}
/// Unicode normalization: "café.dwg" in NFC (single U+00E9) and NFD
/// ("e" + U+0301) are the SAME visual name but different byte strings, so
/// they get different keys — two recents entries, two autosave snapshots,
/// two tabs that look identical to the user. Harm has two edges:
/// - mostly benign: each entry stores/loads its own bytes consistently;
/// - confusing: a user with an NFD-named file on disk and an NFC-named copy
/// from the web picker sees the "same" drawing twice in the Start page and
/// the pair never share an autosave snapshot, so recovering one does not
/// recover the other. Not corruption — a duplicate-entry wart.
#[test]
fn nfc_and_nfd_names_hash_to_distinct_entries() {
let nfc = "caf\u{e9}.dwg"; // U+00E9
let nfd = "cafe\u{301}.dwg"; // e + combining acute
assert_eq!(nfc, nfd.nfc_str()); // visually/normalization-wise the "same" name
assert_ne!(nfc.as_bytes(), nfd.as_bytes());
assert_ne!(name_hash(nfc), name_hash(nfd));
assert_ne!(snapshot_key(nfc), snapshot_key(nfd));
assert_ne!(cache_key(nfc), cache_key(nfd));
}
trait Nfc {
fn nfc_str(&self) -> String;
}
impl Nfc for str {
fn nfc_str(&self) -> String {
// Minimal NFC for the one case under test: precomposed é.
self.replace("e\u{301}", "\u{e9}")
}
}
/// REAL COLLISION (Pollard-rho, ~1.1e9 steps / 208 s in a release build over
/// 10-char printable names): two different drawing FILE NAMES that share one
/// FNV-1a 64 hash — and therefore ONE OPFS entry in BOTH stores. Found on
/// the 10-char stems; because FNV processes bytes sequentially and the two
/// stems reach the SAME intermediate state, appending the same ".dwg" suffix
/// preserves the collision, so these are full realistic filenames.
///
/// The code has no defense: `web_recent::store` writes `cache_key(name)`
/// blindly (web_recent.rs:34) and `web_autosave::snapshot` writes
/// `snapshot_key(name)` blindly (web_autosave.rs:116), each keyed ONLY by
/// the hash. With these two names in play:
/// * the recents copy of the first drawing is OVERWRITTEN by the second
/// (the Start page then reopens the second's bytes under the first's
/// name — and, if the extensions differ, parses them with the WRONG
/// reader: ".dxf" name + DWG bytes);
/// * the autosave index (web_autosave.rs:112-115) keeps BOTH names, but
/// both rows resolve to the same snapshot file — restoring "the first"
/// silently hands back the second drawing's geometry, and deleting one
/// after its restore (web_autosave.rs:137-151 `remove`) kills the
/// other's snapshot too (the dead row is filtered on the next scan).
/// Probability for organically-chosen names is birthday-bound (~2^-64 per
/// pair, ~2^-13 even for a 100-drawing pool), so this is theoretical for
/// real users — but it is a total, silent loss of the uniqueness
/// assumption. Storing the name alongside the bytes and verifying payload
/// identity on read (the index already maps hash->name), or keying on a
/// longer digest (SHA-256/BLAKE3 truncated to 32 hex chars), would close it.
#[test]
fn two_different_names_share_one_key() {
let (a, b) = (COLLISION_A, COLLISION_B);
assert_ne!(a, b, "collision pair must be distinct names");
assert!(a.is_ascii() && b.is_ascii());
assert!(a.ends_with(".dwg") && b.ends_with(".dwg"));
assert_eq!(name_hash(a), name_hash(b), "pinned collision must hold");
assert_eq!(snapshot_key(a), snapshot_key(b));
assert_eq!(cache_key(a), cache_key(b));
assert_eq!(thumbnail_key(a), thumbnail_key(b));
}
// Pollard-rho result (verify_coll / find_b reconstructions, byte-exact):
// stems `"GK@"Qo8if` [22 47 4b 40 22 51 6f 38 69 66] and `%e+k]u[/],`
// [25 65 2b 6b 5d 75 5b 2f 5d 2c] both hash to a95b939b46c157f8.
const COLLISION_A: &str = "\"GK@\"Qo8if.dwg";
const COLLISION_B: &str = "%e+k]u[/],.dwg";
+344
View File
@@ -0,0 +1,344 @@
//! RED-TEAM: hostile inputs against the web autosave restore-name pipeline
//! (`src/io/web_autosave.rs` `recovered_name` / `newer_than_saved` /
//! `cap_evictions`) and the parse-worker extension gate it must satisfy
//! (`crates/ocs_web_worker/src/lib.rs`).
//!
//! `recovered_name` & co. are `pub(crate)` and OPFS is browser-only, so the
//! three pure helpers are copied VERBATIM below (marked) and their behaviour
//! pinned here; every claim that can be exercised through real shipped code
//! (`OpenCADStudio::io::load_bytes`, which derives the format from the name
//! exactly like the worker: `name.rsplit('.').next()`, src/io/mod.rs:722)
//! is tested against the real code.
#![cfg(not(target_arch = "wasm32"))]
const FIXTURE_DXF: &[u8] = include_bytes!("fixtures/embedded-image-print.dxf");
// ── Verbatim copies (pub(crate), invisible to integration tests) ───────────
/// Verbatim copy of src/io/web_autosave.rs `recovered_name` (incl. the
/// `RECOVERED_MARKER` const, inlined).
const RECOVERED_MARKER: &str = " (recovered)";
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
};
// Idempotent: strip every trailing marker, then append exactly one.
while let Some(stripped) = stem.strip_suffix(RECOVERED_MARKER) {
stem = stripped;
}
format!("{stem}{RECOVERED_MARKER}.{ext}")
}
/// Verbatim copy of src/io/web_autosave.rs:52-57.
fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> bool {
match saved_ms {
Some(saved_ms) => autosave_ms > saved_ms,
None => true,
}
}
/// Verbatim copy of src/io/web_autosave.rs:39-43 + 71-84.
#[derive(Debug, Clone)]
struct WebAutosaveEntry {
name: String,
size: u64,
modified_ms: f64,
}
const SNAPSHOT_CAP: usize = 16;
fn cap_evictions(
mut entries: Vec<WebAutosaveEntry>,
cap: usize,
) -> (Vec<WebAutosaveEntry>, Vec<String>) {
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
let overflow = entries.len().saturating_sub(cap);
let mut evicted: Vec<String> = entries
.split_off(entries.len() - overflow)
.into_iter()
.map(|entry| entry.name)
.collect();
evicted.reverse();
(entries, evicted)
}
/// Verbatim copy of the worker's extension gate
/// (crates/ocs_web_worker/src/lib.rs:44-52): the restore name must pass this
/// or the drawing can NEVER be reopened — a restore that always fails is a
/// data-loss trap (the snapshot is only deleted on a successful open).
fn worker_accepts(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase();
matches!(ext.as_str(), "dwg" | "dxf")
}
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
WebAutosaveEntry {
name: name.to_string(),
size: 16,
modified_ms,
}
}
/// Every recovered name must keep an extension the parse worker accepts —
/// otherwise the offered snapshot can never be restored, only discarded.
#[test]
fn recovered_names_that_the_parse_worker_refuses() {
// The extension is preserved verbatim: a ".sv$" name restores as ".sv$",
// which the web worker rejects ("Unsupported file format: .sv$",
// crates/ocs_web_worker/src/lib.rs:45) — while the NATIVE loader sniffs
// the real format for sv$/bak (src/io/mod.rs:723) and opens it anyway.
for input in ["plan.sv$", "plan.bak", "plan.dwf", "plan.dwgx", "pl.an"] {
let output = recovered_name(input);
assert!(
!worker_accepts(&output),
"recovered_name({input:?}) = {output:?} is refused by the parse worker"
);
}
// Real-code proof of the web/native divergence: native `load_bytes`
// derives the format from the name exactly like the worker, BUT sniffs
// sv$/bak — the same bytes+name the worker refuses open natively.
assert!(
OpenCADStudio::io::load_bytes("plan (recovered).sv$", FIXTURE_DXF.to_vec()).is_ok(),
"native loader sniffs sv$ and opens; only the web worker refuses"
);
// Non-sniffed unknown extensions are refused by BOTH loaders (the
// replica gate mirrors the worker; load_bytes is the real thing).
let err = OpenCADStudio::io::load_bytes("plan (recovered).dwf", FIXTURE_DXF.to_vec())
.expect_err("real loader must refuse .dwf");
assert!(err.contains("Unsupported file format"), "{err}");
}
/// FIXED (was BREAK, verified by reading + real-loader proof): a drawing
/// whose FILE NAME is exactly ".dxf" (a dot-file — accepted by BOTH the drop
/// filter and the worker, which split on the last dot) is serialized by
/// autosave as DXF (`source_is_dxf` falls through to
/// `dwg_source_version.is_none()` because `Path::extension(".dxf")` is
/// None). The old `Path::extension`-based `recovered_name` restored it as
/// ".dwg" — feeding DXF bytes to the DWG reader, so the restore could never
/// succeed and the snapshot was re-offered at every boot (it is deleted only
/// on a successful open). The extension split now follows the WORKER
/// convention (`name.rsplit('.').next()`), so ".dxf" restores as ".dxf".
#[test]
fn dotfile_dxf_restores_as_dxf_with_matching_bytes() {
// 1. The drop filter (replicated verbatim from src/io/web_drop.rs)
// accepts ".dxf": rsplit_once('.') yields ("", "dxf").
let dropped = ".dxf"
.rsplit_once('.')
.is_some_and(|(_, extension)| {
matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf")
});
assert!(dropped, "drop filter accepts the dot-file '.dxf'");
// 2. The worker gate (and the real loader) also parse ".dxf" as DXF.
assert!(worker_accepts(".dxf"));
let document = OpenCADStudio::io::load_bytes(".dxf", FIXTURE_DXF.to_vec())
.expect("real loader opens '.dxf' as DXF");
// ...and it is a DXF source, so `source_is_dxf` says DXF (the autosave
// serializes DXF bytes).
assert!(document.dwg_source_version.is_none());
// 3. Worker-convention split: ".dxf" keeps "dxf" as its extension — the
// empty head falls back to the whole stored name as the stem.
let restored = recovered_name(".dxf");
assert_eq!(restored, ".dxf (recovered).dxf");
// 4. Real loader: DXF bytes under the recovered name now go to the DXF
// reader and OPEN — the restore door is unjammed.
assert!(worker_accepts(&restored));
assert!(
OpenCADStudio::io::load_bytes(&restored, FIXTURE_DXF.to_vec()).is_ok(),
"DXF bytes under {restored:?} must parse"
);
// And the dot-file's DWG twin keeps its DWG extension the same way.
assert_eq!(recovered_name(".dwg"), ".dwg (recovered).dwg");
}
/// FIXED (was BREAK): the "(recovered)" suffix used to STACK — the restored
/// tab's `current_path` becomes the recovered name, the next autosave round
/// snapshots it under THAT name, and the next restore appended another
/// marker ("x (recovered) (recovered).dwg"), one suffix per crash cycle,
/// unbounded. `recovered_name` now strips every trailing marker before
/// appending exactly one, so the name is idempotent across any number of
/// crash cycles.
#[test]
fn recovered_marker_never_stacks_across_crash_cycles() {
// A single cycle: the marker is appended once, then stays at one.
let mut name = "plan.dwg".to_string();
for cycle in 1..=50 {
name = recovered_name(&name);
assert_eq!(
name,
"plan (recovered).dwg",
"cycle {cycle}: the recovered name must be idempotent"
);
assert!(worker_accepts(&name), "{name:?} must stay parseable");
}
// Legacy stacked names (written by the OLD behaviour before the fix)
// collapse back to a single marker.
assert_eq!(
recovered_name("plan (recovered) (recovered).dwg"),
"plan (recovered).dwg"
);
assert_eq!(
recovered_name(&format!("plan{} (recovered).dwg", " (recovered)".repeat(40))),
"plan (recovered).dwg"
);
// Extensionless stacked names de-stack too, then gain the DXF default.
assert_eq!(recovered_name("plan (recovered)"), "plan (recovered).dxf");
// An embedded (non-suffix) marker is left alone.
assert_eq!(
recovered_name("(recovered) plan.dwg"),
"(recovered) plan (recovered).dwg"
);
// The cycle still re-offers after a crash (no recent save of its own
// name) — that property is unchanged, only the name growth is fixed.
assert!(newer_than_saved(0.0, None));
}
/// Names that survive intact: no extension → DXF fallback (matching how
/// autosave serializes untitled tabs as DXF via `source_is_dxf` →
/// `dwg_source_version.is_none()`), multi-dot keeps the final extension,
/// unicode is passed through, path separators are stripped from the head so
/// the result can never escape the display-name namespace (OPFS keys are
/// FNV hashes, never the name).
#[test]
fn benign_hostile_names_round_trip_parseable() {
for (input, expected, dxf_bytes) in [
// Extensionless names restore as DXF — what autosave serializes
// untitled tabs as (was ".dwg" under the old Path::extension split).
("Drawing1", "Drawing1 (recovered).dxf", true),
("a.b.dxf", "a.b (recovered).dxf", true),
("", " (recovered).dxf", false),
("plán.dwg", "plán (recovered).dwg", false),
("図面 1.dwg", "図面 1 (recovered).dwg", false),
] {
let got = recovered_name(input);
assert_eq!(got, expected);
assert!(worker_accepts(&got), "{got:?} must stay parseable");
// Real loader opens the name whenever the bytes match the extension
// it implies (DXF fixture bytes for .dxf outcomes).
if dxf_bytes {
assert!(
OpenCADStudio::io::load_bytes(&got, FIXTURE_DXF.to_vec()).is_ok(),
"{got}"
);
}
}
// Windows path semantics: separators in the stored name never survive
// into the recovered name as separators.
for hostile in ["..\\evil.dxf", "../evil.dxf", "a/b\\c.dwg"] {
let got = recovered_name(hostile);
assert!(
!got.contains('/') && !got.contains('\\'),
"recovered name must not carry path separators: {got:?}"
);
}
// A 4096-char name: stem grows, hash key stays 16 hex chars (see
// web_name_hash_redteam.rs) — no length-based failure.
let long = "x".repeat(4096 - ".dwg".len());
let got = recovered_name(&format!("{long}.dwg"));
assert!(worker_accepts(&got));
assert_eq!(got.len(), long.len() + " (recovered).dwg".len());
}
// ── newer_than_saved ────────────────────────────────────────────────────────
#[test]
fn newer_than_saved_hostile_timestamps() {
// Equal mtimes: same save round, not offered (existing behaviour).
assert!(!newer_than_saved(1000.0, Some(1000.0)));
// Negative mtimes compare numerically.
assert!(newer_than_saved(-5.0, Some(-10.0)));
assert!(!newer_than_saved(-10.0, Some(-5.0)));
// NaN autosave mtime: `NaN > x` is false — silently NOT offered. The
// snapshot survives (age never deletes), so worst case is a hidden
// snapshot, not a lost one. Deterministic, no poison.
assert!(!newer_than_saved(f64::NAN, Some(1000.0)));
// NaN saved mtime likewise hides the offer forever.
assert!(!newer_than_saved(1000.0, Some(f64::NAN)));
// Extremes behave numerically: +inf / f64::MAX are "newer" than anything
// finite; -inf is older than everything, including f64::MIN.
assert!(newer_than_saved(f64::MAX, Some(f64::MIN)));
assert!(newer_than_saved(f64::INFINITY, Some(f64::MAX)));
assert!(!newer_than_saved(f64::NEG_INFINITY, Some(f64::MIN)));
}
// ── cap_evictions ───────────────────────────────────────────────────────────
#[test]
fn cap_evictions_boundaries() {
// Empty: nothing kept, nothing evicted.
let (kept, evicted) = cap_evictions(Vec::new(), SNAPSHOT_CAP);
assert!(kept.is_empty() && evicted.is_empty());
// Exactly the cap: nothing evicted.
let entries: Vec<_> = (0..SNAPSHOT_CAP).rev().map(|i| entry(&format!("d{i}"), i as f64)).collect();
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
assert!(evicted.is_empty());
assert_eq!(kept.len(), SNAPSHOT_CAP);
assert_eq!(kept.first().unwrap().name, format!("d{}", SNAPSHOT_CAP - 1));
// Exactly cap+1: the single oldest is evicted, survivors newest-first.
let entries: Vec<_> = (0..=SNAPSHOT_CAP)
.map(|i| entry(&format!("d{i}"), i as f64))
.collect();
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
assert_eq!(evicted, ["d0"]);
assert_eq!(kept.last().unwrap().name, "d1");
assert_eq!(kept.first().unwrap().name, format!("d{SNAPSHOT_CAP}"));
// Unsorted input still yields newest-first offer order.
let (kept, _) = cap_evictions(
vec![entry("old", 1.0), entry("newest", 9.0), entry("mid", 5.0)],
SNAPSHOT_CAP,
);
assert_eq!(
kept.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
["newest", "mid", "old"]
);
// All-equal mtimes: the stable sort keeps index (push) order, `split_off`
// takes the tail, and the final `reverse()` flips it — so among equal
// mtimes the MOST RECENTLY INDEXED snapshot is deleted first. Arbitrary
// (ties carry no age signal) but deterministic.
let entries: Vec<_> = (0..SNAPSHOT_CAP + 2).map(|i| entry(&format!("e{i}"), 42.0)).collect();
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
assert_eq!(evicted, ["e17", "e16"]);
assert_eq!(kept.first().unwrap().name, "e0");
// Degenerate cap 0 evicts everything.
let (_, evicted) = cap_evictions(vec![entry("a", 1.0), entry("b", 2.0)], 0);
assert_eq!(evicted, ["a", "b"]); // oldest-first
}
/// NaN mtimes do NOT poison the choice into a panic or a wrong split — but
/// `total_cmp` ranks NaN ABOVE every finite mtime, so NaN entries sort as
/// "newest": they are always KEPT and cannot be evicted, while they push real
/// snapshots past the cap into eviction. `newer_than_saved(NaN, _)` then
/// refuses to offer them, so a NaN-mtime snapshot is undead: never offered,
/// never evicted, squatting one cap slot forever.
#[test]
fn cap_evictions_nan_mtimes_sort_as_newest_and_crowd_out_real_snapshots() {
let mut entries: Vec<_> = (0..SNAPSHOT_CAP)
.map(|i| entry(&format!("real{i}"), i as f64))
.collect();
entries.push(entry("nan", f64::NAN));
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
assert_eq!(kept.first().unwrap().name, "nan", "NaN sorts above +inf");
assert_eq!(evicted, ["real0"], "the oldest REAL snapshot pays for it");
assert!(!newer_than_saved(f64::NAN, Some(0.0)), "and is never offered");
// Mixed infinities stay ordered.
let (kept, _) = cap_evictions(
vec![entry("neg_inf", f64::NEG_INFINITY), entry("inf", f64::INFINITY), entry("zero", 0.0)],
SNAPSHOT_CAP,
);
assert_eq!(
kept.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
["inf", "zero", "neg_inf"]
);
}