From 3eab00bfbf7e1bcf802b54762e8240a654a16abc Mon Sep 17 00:00:00 2001 From: Afonsop Date: Wed, 16 Sep 2026 13:56:08 +0100 Subject: [PATCH] web: fix red-team breaks in restore, drop-open, and plot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- locales/ar-SA/opencadstudio.ftl | 2 + locales/bg-BG/opencadstudio.ftl | 2 + locales/cs-CZ/opencadstudio.ftl | 2 + locales/de-DE/opencadstudio.ftl | 2 + locales/el-GR/opencadstudio.ftl | 2 + locales/en-US/opencadstudio.ftl | 2 + locales/es-ES/opencadstudio.ftl | 2 + locales/fi-FI/opencadstudio.ftl | 2 + locales/fr-FR/opencadstudio.ftl | 2 + locales/hi-IN/opencadstudio.ftl | 2 + locales/hu-HU/opencadstudio.ftl | 2 + locales/it-IT/opencadstudio.ftl | 2 + locales/ja-JP/opencadstudio.ftl | 2 + locales/ko-KR/opencadstudio.ftl | 2 + locales/nl-NL/opencadstudio.ftl | 2 + locales/pl-PL/opencadstudio.ftl | 2 + locales/pt-BR/opencadstudio.ftl | 2 + locales/ru-RU/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + locales/zh-CN/opencadstudio.ftl | 2 + locales/zh-TW/opencadstudio.ftl | 2 + src/app/mod.rs | 5 + src/app/update/dialog.rs | 17 ++ src/app/update/file.rs | 106 +++++-- src/app/update/mod.rs | 17 +- src/io/pdf_export.rs | 11 + src/io/web_autosave.rs | 119 ++++++-- src/io/web_drop.rs | 43 ++- src/locale_catalog.rs | 2 + src/sys.rs | 34 ++- tests/model_extents_plot_floor_redteam.rs | 297 +++++++++++++++++++ tests/web_drop_filter_redteam.rs | 166 +++++++++++ tests/web_export_stem_redteam.rs | 95 ++++++ tests/web_name_hash_redteam.rs | 134 +++++++++ tests/web_recovered_name_redteam.rs | 344 ++++++++++++++++++++++ 35 files changed, 1356 insertions(+), 76 deletions(-) create mode 100644 tests/model_extents_plot_floor_redteam.rs create mode 100644 tests/web_drop_filter_redteam.rs create mode 100644 tests/web_export_stem_redteam.rs create mode 100644 tests/web_name_hash_redteam.rs create mode 100644 tests/web_recovered_name_redteam.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index cf7029c7a..7641c9cb8 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = المحاذاة: diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 0b05cd024..ce7faff6a 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -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 = Подравняване: diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 9a71e4089..fc43d451c 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Zarovnání: diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 583328919..c94f83cea 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Ausrichtung: diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index eca71506c..da27a4211 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -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 <πλήκτρο> <εντολή>) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c05a98e77..945cad9cb 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Alignment: .background-fill-enabled = Background fill enabled diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 3b595a361..d47c5356f 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Alineación: diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6712eacb6..75d8bdcd2 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Tasaus: diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index c3027d400..8bea01729 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Alignement : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index c26a31978..c49ca0b0a 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = संरेखण: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 3e4cd8dde..58bc9e4b3 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Igazítás: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index fb71c8e2f..9ff22a07a 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Allineamento: diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 8b959ad50..e5ee4c8e5 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -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 を使用) .alignment-indented-label-title-case = 配置: diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index ba320253b..901e39aa9 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -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 =정렬: diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index a62f2992a..281ca4a0e 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Uitlijning: diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index baa5b629c..e5161f29a 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Wyrównanie: diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index cecec8c54..2488e8d4b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Alinhamento: diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 03a19b8a7..0742a0487 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Выравнивание: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 1142082cb..a85661a8e 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = Adapt: .background-fill-enabled = Arka plan doldurma etkinleştir etkinleştirildi diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 9da568111..d5bf92dce 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -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 ) .alignment-indented-label-title-case = 对齐: diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 49ad5652f..ae4544304 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -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 = 對齊: diff --git a/src/app/mod.rs b/src/app/mod.rs index a98f39f35..cef3daeb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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")] diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index fa0134635..842d3dbfb 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -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] = diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 556d12e63..96706865d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 { + 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 { + pub(in crate::app) fn exit_app(&mut self) -> Task { self.cleanup_autosaves(); iced::exit() } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6e527c462..76b20c4cc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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 diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index de43793c4..903f7375e 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -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, diff --git a/src/io/web_autosave.rs b/src/io/web_autosave.rs index cdfc53a6e..775ebd21e 100644 --- a/src/io/web_autosave.rs +++ b/src/io/web_autosave.rs @@ -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, String> { let directory = autosave_directory(false).await?; let mut entries = Vec::new(); let mut dead: Vec = 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 { - 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, 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] diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index f8083cf49..8f5ec248c 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -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> { .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) { +/// 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) -> 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, 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, 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" + ) } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 417b39367..fbade38ad 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -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, } } diff --git a/src/sys.rs b/src/sys.rs index c23883a65..af4c2c819 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -385,9 +385,11 @@ pub fn show_pdf_preview(name: &str, pdf_bytes: &[u8], page_pngs: &[Vec], 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], 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], 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 = wasm_bindgen::closure::Closure::new(move || { download_bytes(&name, &bytes); }); let click: js_sys::Function = closure.as_ref().unchecked_ref::().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> { 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>> = + 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"))] diff --git a/tests/model_extents_plot_floor_redteam.rs b/tests/model_extents_plot_floor_redteam.rs new file mode 100644 index 000000000..89c2dd775 --- /dev/null +++ b/tests/model_extents_plot_floor_redteam.rs @@ -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); +} diff --git a/tests/web_drop_filter_redteam.rs b/tests/web_drop_filter_redteam.rs new file mode 100644 index 000000000..a173476d0 --- /dev/null +++ b/tests/web_drop_filter_redteam.rs @@ -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 = (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:?}" + ); + } + } + } + } +} diff --git a/tests/web_export_stem_redteam.rs b/tests/web_export_stem_redteam.rs new file mode 100644 index 000000000..c1ef75fa8 --- /dev/null +++ b/tests/web_export_stem_redteam.rs @@ -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"); +} diff --git a/tests/web_name_hash_redteam.rs b/tests/web_name_hash_redteam.rs new file mode 100644 index 000000000..f33c98fda --- /dev/null +++ b/tests/web_name_hash_redteam.rs @@ -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"; diff --git a/tests/web_recovered_name_redteam.rs b/tests/web_recovered_name_redteam.rs new file mode 100644 index 000000000..9917cae03 --- /dev/null +++ b/tests/web_recovered_name_redteam.rs @@ -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) -> 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, + cap: usize, +) -> (Vec, Vec) { + entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms)); + let overflow = entries.len().saturating_sub(cap); + let mut evicted: Vec = 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::>(), + ["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::>(), + ["inf", "zero", "neg_inf"] + ); +}