From f015c3b5fa85d8168b4de6f60e2c6888a675df35 Mon Sep 17 00:00:00 2001 From: Afonsop Date: Mon, 12 Oct 2026 11:24:20 +0300 Subject: [PATCH] fix: native-bpp decode accounting, GIF frame bombs, streaming remote bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second adversarial round (with empirical reproduction against the vendored image 0.25) found the budget contract still had holes: - The probe billed 4 bytes/px, but 16-bit formats allocate their native layout during decode (8/px for RGBA16 PNG; TIFF stacks ~3x codec scratch on top). In-budget 16-bit images were falsely refused with self-contradictory notes, and the decode transient could exceed the budget the tiers claimed to enforce. The probe now reads the color type alongside the dimensions and bills the real peak (native layout + RGBA where they coexist, plus the TIFF codec factor), so refusals are honest and per-platform-correct, and the TIFF allowance follows the same accounting. - GIF: the probe reads the LOGICAL SCREEN, but a crafted frame may declare itself arbitrarily larger (empirically: a 10x10 probed screen with an 8192x8192 frame — 320 MB and 29 s from a probe-claimed-400-byte image). The GIF frame buffer is now capped to the probed size plus slack; frame bombs refuse like any other oversize. - Remote bodies on web streamed incrementally with the 64 MB cap enforced DURING the read (array_buffer() materialized a hostile chunked reply in full before any check could fire), aborting and cancelling the reader the moment the cap is crossed. - Degenerate raster entities (zero/non-finite size or vectors) no longer produce NaN placement math that aborted whole PDF exports — they resolve to the broken-reference placeholder. - Same-name resolution now has a deterministic winner (newest registration, via insertion order) instead of HashMap iteration order; URL query strings no longer break by-name matching; the image pick matches its refusal note by the quoted file name (substring collisions like a.tif/2011.tif attributed errors to the wrong file); a dropped remote outcome at the queue cap triggers one unconditional catch-up re-resolution instead of waiting for another event; the unknown-size refusal says so instead of printing 0x0; and the build script fails cleanly when there are no fonts to verify. (cherry picked from commit c6db94e8) Ported onto main (#1717 review): the build-web.sh hunk stays out with the wrapper; the new messages are cataloged (image refusal under the file group it is looked up in, plus the extraction cap, shared-copy warning and plot-preview task title that were missing), and the web SAVEALL failure reuses the existing SAVEALL message. --- Cargo.toml | 2 + locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 4 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/update/file.rs | 4 +- src/app/update/mod.rs | 8 + src/io/mod.rs | 2 +- src/locale_catalog.rs | 4 + src/scene/model/image_model.rs | 294 ++++++++++++++++++++++++++------ 27 files changed, 286 insertions(+), 52 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 293562e29..6ef6bf45c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,6 +179,8 @@ web-sys = { version = "=0.3.85", features = [ "DataTransfer", "FileList", "Event", + "ReadableStreamDefaultReader", + "ReadableStream", ] } iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 01badc366..1718cd00b 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2412,6 +2412,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. + .image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = التحليل __ocs_fmt_0__ ملث · التنظيف __ocs_fmt_1__ ملث · الذاكرة المؤقتة __ocs_fmt_2__ ملث · المرجع الخارجي __ocs_fmt_3__ ملث · الإجمالي __ocs_fmt_4__ ملث .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: احفظ الرسم أولًا (ليس له مسار ملف بعد). .autosaved-1-drawing = حُفظ رسم واحد تلقائيًا diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index ddf3325a6..0ca293595 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. + .image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = анализ __ocs_fmt_0__ms · изчистване __ocs_fmt_1__ms · кешове __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общо __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: първо запази чертежа (все още няма път до файл). .autosaved-1-drawing = Автоматично запазен 1 чертеж diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index b4946dbcc..f2936b718 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. + .image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analyzovat __ocs_fmt_0__ms · vyčistit __ocs_fmt_1__ms · mezipaměti __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · celkem __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: nejprve uložte výkres (ještě nemá žádnou cestu k souboru). .autosaved-1-drawing = Automaticky uložen 1 výkres diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index f5f8e181c..4b24920f4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2400,6 +2400,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. + .image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: Speichern Sie die Zeichnung zuerst (sie hat noch keinen Dateipfad). .autosaved-1-drawing = 1 Zeichnung automatisch gespeichert diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 8feb3116f..fa9049697 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. + .image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ανάλυση __ocs_fmt_0__ms · εκκαθάριση __ocs_fmt_1__ms · κρυφές μνήμες __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · σύνολο __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: αποθηκεύστε πρώτα το σχέδιο (δεν έχει ακόμη διαδρομή αρχείου). .autosaved-1-drawing = Αυτόματη αποθήκευση 1 σχεδίου diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 268de84ce..21ee79120 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1888,6 +1888,7 @@ common = .enter-a-github-url-or-repository-in-owner-repo-format = Enter a GitHub URL or repository in owner/repo format. .dwgunits-convert-drawing-to = DWGUNITS Convert drawing to: .opened-plot-preview = Opened plot preview. + .plot-preview-task = Plot preview .auto-constrain = Auto Constrain .show-all = Show All @@ -2400,6 +2401,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. + .image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · purge __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: save the drawing first (it has no file path yet). .autosaved-1-drawing = Autosaved 1 drawing @@ -2441,6 +2443,8 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: saved __ocs_fmt_0__ drawing(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ already has a save running .saveall-failed-e = SAVEALL: __ocs_fmt_0__ failed: __ocs_fmt_1__ + .extraction-max-sources-web = At most __ocs_fmt_0__ drawings can be added in the browser. + .web-already-open-shared-copy = "__ocs_fmt_0__" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other. .load-linetype-file = Load linetype file .script-path-to-the-scr-file = SCRIPT path to the .scr file: .script-cannot-read-p-e = SCRIPT: cannot read __ocs_fmt_0__: __ocs_fmt_1__ diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index ddeacdd3b..725c64892 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2400,6 +2400,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. + .image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: guarde primero el dibujo (todavía no tiene una ruta de archivo). .autosaved-1-drawing = Se guardó automáticamente 1 dibujo diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 948d33bf9..ec69cc927 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. + .image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = jäsentää __ocs_fmt_0__ms · tyhjentää __ocs_fmt_1__ms · välimuistit __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · yhteensä __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: tallenna piirustus ensin (sillä ei ole vielä tiedostopolkua). .autosaved-1-drawing = Automaattisesti tallennettu 1 piirros diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index f17e69955..b627ebae9 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2400,6 +2400,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. + .image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = Analyse : __ocs_fmt_0__ ms, purge : __ocs_fmt_1__ ms, caches : __ocs_fmt_2__ ms, références externes : __ocs_fmt_3__ ms, total : __ocs_fmt_4__ ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE : enregistrer le dessin en premier (il n'a pas encore de chemin de fichier). .autosaved-1-drawing = 1 dessin enregistré automatiquement diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index aed7d6fa5..bf8d96f8d 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2388,6 +2388,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। + .image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता। .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = पार्स __ocs_fmt_0__ms · purge __ocs_fmt_1__Ms · कैश __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · कुल __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: पहले ड्रॉइंग सहेजें (अभी फ़ाइल पथ निर्धारित नहीं है)। .autosaved-1-drawing = 1 ड्रॉइंग स्वतः सहेजी गई diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 888f38580..8c8ccff2b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2412,6 +2412,7 @@ command = file = .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. + .image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = elemzése __ocs_fmt_0__ms · __ocs_fmt_1__ms törlése · gyorsítótárak __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · összesen __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: először mentse a rajzot (még nincs fájl elérési útja). .autosaved-1-drawing = Automatikusan mentett 1 rajzot diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index ec070c9ad..046d92cf8 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. + .image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizza __ocs_fmt_0__ms · elimina __ocs_fmt_1__ms · memorizza nella cache __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totale __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: salva prima il disegno (non ha ancora il percorso del file). .autosaved-1-drawing = 1 disegno salvato automaticamente diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index f9b1bb731..219f59a04 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2390,6 +2390,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 + .image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ms · パージ __ocs_fmt_1__ms · キャッシュ __ocs_fmt_2__ms · 外部参照 __ocs_fmt_3__ms · 合計 __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: 先に図面を保存してください(まだファイルパスがありません)。 .autosaved-1-drawing = 1 件の図面を自動保存しました diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 561407809..168d6956d 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. + .image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = __ocs_fmt_0__ms 구문 분석 · __ocs_fmt_1__ms 제거 · 캐시 __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · 총 __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: 먼저 도면을 저장합니다(아직 파일 경로가 없음). .autosaved-1-drawing = 그림 1개 자동 저장됨 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index f1087f1be..7b3c61ceb 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2400,6 +2400,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. + .image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ontleed __ocs_fmt_0__ms · zuivering __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totaal __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: sla de tekening eerst op (er is nog geen bestandspad). .autosaved-1-drawing = Automatisch opslaan van 1 tekening diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 234047dad..aa99d53a4 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. + .image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizuj __ocs_fmt_0__ms · usuwaj __ocs_fmt_1__ms · pamięci podręczne __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · łącznie __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: najpierw zapisz rysunek (nie ma on jeszcze ścieżki do pliku). .autosaved-1-drawing = Automatycznie zapisano 1 rysunek diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index baab11479..af4be1784 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2400,6 +2400,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. + .image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análise __ocs_fmt_0__ms · limpeza __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · ref. externa __ocs_fmt_3__ms · total __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: salve o desenho primeiro (ele ainda não tem um caminho de arquivo). .autosaved-1-drawing = 1 desenho salvo automaticamente diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 8b2c04346..5baa9bd16 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2403,6 +2403,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. + .image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · чистка __ocs_fmt_1__ms · кэши __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общая __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: сначала сохраните чертёж (путь к файлу ещё не задан). .autosaved-1-drawing = Автоматически сохранён 1 чертёж diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ae0b0c3c9..61006001f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2384,6 +2384,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. + .image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez. .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ayrıştırma __ocs_fmt_0__ ms · temizleme __ocs_fmt_1__ ms · önbellekler __ocs_fmt_2__ ms · xref __ocs_fmt_3__ ms · toplam __ocs_fmt_4__ ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: önce çizimi kaydedin; çizimin henüz bir dosya yolu yok. .autosaved-1-drawing = 1 çizim otomatik kaydedildi diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 9b68e5fe7..13d29f252 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2387,6 +2387,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 + .image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 缓存 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 总计 __ocs_fmt_4__ 毫秒 .archive-save-the-drawing-first-it-has-no-file = ARCHIVE:请先保存图形(尚未设置文件路径)。 .autosaved-1-drawing = 自动保存了 1 个绘图 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 2754c36a9..0000b1422 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2399,6 +2399,7 @@ command = file = .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 + .image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。 .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 快取 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 總計 __ocs_fmt_4__ 毫秒 .archive-save-the-drawing-first-it-has-no-file = ARCHIVE:請先儲存圖形(尚未設定檔案路徑)。 .autosaved-1-drawing = 自動儲存了 1 個繪圖 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 3813603c3..03330fd51 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2484,8 +2484,8 @@ impl OpenCADStudio { self.tabs[i].dirty = false; } } - Err(error) => self.command_line.push_error( - crate::tf!("SAVEALL: {} failed: {error}", entry.path.display()).as_ref(), + Err(e) => self.command_line.push_error( + crate::tf!("SAVEALL: {} failed: {e}", entry.path.display()).as_ref(), ), } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d277ae755..ea13822f6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -734,6 +734,14 @@ impl OpenCADStudio { } } registered.extend(fetched); + if crate::scene::model::image_model::take_overflowed() { + // A settled fetch's rebuild trigger was dropped at the + // outcome cap; do one unconditional catch-up pass over + // every registered name. + registered.extend( + crate::scene::model::image_model::registered_image_names(), + ); + } if !registered.is_empty() { for tab in self.tabs.iter_mut() { tab.scene.resolve_registered_image_names(®istered); diff --git a/src/io/mod.rs b/src/io/mod.rs index d4a836592..15f71ccd2 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -2045,7 +2045,7 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { // references, which must survive for their own drains. crate::scene::model::image_model::decode_refusals() .iter() - .find(|note| note.contains(short.as_str())) + .find(|note| note.contains(&format!("\"{}\"", short))) .cloned() .unwrap_or_else(|| format!("Unsupported or corrupt image \"{short}\"")) })?; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fc2630f20..a95b414ca 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2280,6 +2280,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Opening OCS Discussions for help and questions..." => Some(("view", "opening-ocs-discussions-for-help-and-questions")), "Opening OCS Web..." => Some(("view", "opening-ocs-web")), "\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("file", "already-loaded-different-contents-kept")), + "\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")), "Opening Patreon page..." => Some(("view", "opening-patreon-page")), "Opening feedback page..." => Some(("view", "opening-feedback-page")), "Opening file" => Some(("ui", "opening-file")), @@ -2701,6 +2702,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")), "SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")), "SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")), + "At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser." => Some(("file", "extraction-max-sources-web")), + "\"{}\" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other." => Some(("file", "web-already-open-shared-copy")), "SAVETIME autosave interval in minutes (0 = off):" => Some(("styles", "savetime-autosave-interval-in-minutes-0-off")), "SAVETIME = {}" => Some(("styles", "savetime")), "SCALE Specify base point [%{count} objects]:" => Some(("modify", "scale-specify-base-point-count-objects")), @@ -5281,6 +5284,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Enter a GitHub URL or repository in owner/repo format." => Some(("common", "enter-a-github-url-or-repository-in-owner-repo-format")), "DWGUNITS Convert drawing to:" => Some(("common", "dwgunits-convert-drawing-to")), "Opened plot preview." => Some(("common", "opened-plot-preview")), + "Plot preview" => Some(("common", "plot-preview-task")), "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):" => Some(("common", "select-objects-or-settings-selected-enter-to-apply")), "%{cmd} Select objects or [Settings]:" => Some(("common", "select-objects-or-settings")), "+ Add parameter" => Some(("common", "add-parameter")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 7d186e45f..6f555ede8 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -183,6 +183,19 @@ impl ImageModel { pub fn from_raster_image(img: &codec::entities::RasterImage) -> Option { let w = img.size.x; let h = img.size.y; + // A degenerate or non-finite size/vectors (hostile entity data) + // produce NaN placement math that aborts whole PDF exports; treat + // it as an unresolvable reference instead. + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return None; + } + let vector_ok = |v: &codec::Vector3| { + let (x, y, z) = (v.x, v.y, v.z); + x.is_finite() && y.is_finite() && z.is_finite() && (x != 0.0 || y != 0.0 || z != 0.0) + }; + if !vector_ok(&img.u_vector) || !vector_ok(&img.v_vector) { + return None; + } // Model-space geometry is drawn in (WCS - world_offset) so large UTM- // scale coordinates stay within f32 precision; offset the image too. // Corners come from a large insertion point plus small u/v spans. @@ -504,6 +517,14 @@ fn image_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// Insertion order of the source cache's keys, so by-name resolution has a +/// deterministic winner (the MOST RECENT registration) instead of HashMap's +/// unspecified iteration order. +fn source_order() -> &'static Mutex> { + static ORDER: OnceLock>> = OnceLock::new(); + ORDER.get_or_init(|| Mutex::new(Vec::new())) +} + fn source_cache() -> &'static Mutex>>> { static SOURCES: OnceLock>>>> = OnceLock::new(); SOURCES.get_or_init(|| Mutex::new(HashMap::new())) @@ -536,10 +557,18 @@ pub(crate) fn same_file_name(a: &str, b: &str) -> bool { /// finally supplies a missing reference) re-decodes instead of replaying a /// stale success or a cached failure. pub fn register_source(path: &str, bytes: Arc>) { - source_cache() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .insert(path.to_string(), bytes); + { + let mut sources = source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !sources.contains_key(path) { + source_order() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(path.to_string()); + } + sources.insert(path.to_string(), bytes); + } if let Ok(mut cache) = image_cache().lock() { cache.retain(|cached, _| !same_file_name(cached, path)); } @@ -574,10 +603,17 @@ fn registered_bytes_by_name(path: &str) -> Option>> { let cache = source_cache() .lock() .unwrap_or_else(|error| error.into_inner()); - cache + // Deterministic winner: walk the insertion order newest-first so the + // most recent drop/pick wins, instead of HashMap iteration order. + let order = source_order() + .lock() + .unwrap_or_else(|error| error.into_inner()); + order .iter() - .find(|(key, _)| same_file_name(key, path)) - .map(|(_, bytes)| Arc::clone(bytes)) + .rev() + .filter(|key| cache.contains_key(*key)) + .find(|key| same_file_name(key, path)) + .and_then(|key| cache.get(key).cloned()) } /// Original image file bytes registered for a reference, resolved the way @@ -701,12 +737,51 @@ enum DecodeTier { Full, /// Decode fully, then downsample to the display cap before storing. Downsample, - /// Full RGBA would exceed `MAX_DECODE_BYTES`. The decode still runs with - /// the budget enforced through `image::Limits`: it succeeds on a build - /// with headroom (native) or errors into a refusal (wasm). + /// The decode's peak memory would exceed `MAX_DECODE_BYTES`. The probe + /// bills the peak of the decoder's NATIVE bytes per pixel and the final + /// RGBA buffer (4/px): both transiently coexist mid-decode on 16-bit + /// formats, and the decoder's own limits reserve at native bpp — so an + /// image is refused up front when either exceeds the budget, without + /// decoding. OverBudget, } +/// The decoder's own memory accounting for a format: how many bytes per +/// pixel the DECODE transiently needs (native layout, plus the RGBA output +/// where they coexist) and any codec-specific scratch multiplier on top. +struct NativeFootprint { + /// Peak bytes per pixel during decode (native bpp + RGBA where both + /// live simultaneously; plain 8-bit formats overlap them in place). + peak_bpp: u64, + /// Extra codec scratch allowance the decoder stacks on the reserve + /// (image's TIFF codec needs ~3x in the worst strip/tile layout). + codec_factor: u64, +} + +fn native_footprint(format: Option, color: Option) -> NativeFootprint { + let native_bpp = color.map_or(4u64, |color| match color { + image::ColorType::L8 => 1, + image::ColorType::La8 => 2, + image::ColorType::Rgb8 => 3, + image::ColorType::Rgba8 => 4, + image::ColorType::L16 => 2, + image::ColorType::La16 => 4, + image::ColorType::Rgb16 => 6, + image::ColorType::Rgba16 => 8, + image::ColorType::Rgb32F => 12, + image::ColorType::Rgba32F => 16, + _ => 4, + }); + // A 16-bit frame and its RGBA conversion can coexist; an 8-bit one is + // converted with at most a small row buffer in flight. + let peak_bpp = if native_bpp > 4 { native_bpp + 4 } else { 4 }; + let codec_factor = match format { + Some(image::ImageFormat::Tiff) => 3, + _ => 1, + }; + NativeFootprint { peak_bpp, codec_factor } +} + /// Budget decision for probed pixel dimensions. Pure and cfg-independent /// when the budgets are passed explicitly, so tests can probe arbitrary /// budgets; the decode path always passes this target's constants. @@ -715,9 +790,27 @@ fn tier_for( height: u32, max_decode_bytes: u64, max_display_pixels: u64, +) -> DecodeTier { + tier_for_native(width, height, 4, 1, max_decode_bytes, max_display_pixels) +} + +/// [`tier_for`] with the probed format's native footprint: the caller +/// supplies the probe's color type and format so 16-bit images are billed +/// at what their decode actually allocates. +fn tier_for_native( + width: u32, + height: u32, + peak_bpp: u64, + codec_factor: u64, + max_decode_bytes: u64, + max_display_pixels: u64, ) -> DecodeTier { let pixels = width as u64 * height as u64; - if pixels.saturating_mul(4) > max_decode_bytes { + if pixels + .saturating_mul(peak_bpp) + .saturating_mul(codec_factor) + > max_decode_bytes + { DecodeTier::OverBudget } else if pixels > max_display_pixels { DecodeTier::Downsample @@ -766,24 +859,45 @@ fn decode_with_budgets( max_decode_bytes: u64, max_display_pixels: u64, ) -> DecodeAttempt { - // Probe pass: header dimensions only, no pixel decode. + // Probe pass: header dimensions AND color type, no pixel decode. The + // probe reader carries the full budget too so constructing a decoder on + // a huge-but-in-budget header cannot itself fail on image's default + // 512 MiB probe limits. let probed = open() .and_then(|reader| reader.with_guessed_format()) .ok() - .and_then(|reader| reader.into_dimensions().ok()); - let tier = probed.map_or(DecodeTier::Full, |(width, height)| { - tier_for(width, height, max_decode_bytes, max_display_pixels) + .and_then(|mut reader| { + let format = reader.format(); + let mut probe_limits = image::Limits::default(); + probe_limits.max_alloc = Some(max_decode_bytes); + reader.limits(probe_limits); + use image::ImageDecoder as _; + let decoder = reader.into_decoder().ok()?; + let (width, height) = decoder.dimensions(); + let color = decoder.color_type(); + Some((width, height, format, color)) + }); + let tier = probed.map_or(DecodeTier::Full, |(width, height, format, color)| { + let footprint = native_footprint(format, Some(color)); + tier_for_native( + width, + height, + footprint.peak_bpp, + footprint.codec_factor, + max_decode_bytes, + max_display_pixels, + ) }); - // Trust the probe, not the decoder limits: `Limits::reserve` checks the - // decoder's NATIVE bytes-per-pixel, so a grayscale/RGB JPEG/PNG/BMP over - // the RGBA budget decodes fine and then `into_rgba8()` balloons to up to - // 4x it — exactly the tab death this exists to prevent. The probed - // dimensions already prove it cannot fit; refuse without decoding. + // Trust the probe, not the decoder limits: the tier above bills the + // decode's real peak (native bpp, codec scratch), so an OverBudget + // image provably cannot fit; refuse without decoding. This covers the + // grayscale/RGB over-RGBA-budget balloon AND the 16-bit formats whose + // native layout is double the RGBA size. if tier == DecodeTier::OverBudget { return DecodeAttempt::Refused { - width: probed.map_or(0, |(width, _)| width), - height: probed.map_or(0, |(_, height)| height), + width: probed.map_or(0, |(width, _, _, _)| width), + height: probed.map_or(0, |(_, height, _, _)| height), }; } @@ -792,24 +906,32 @@ fn decode_with_budgets( Err(_) => return DecodeAttempt::Failed, }; // For formats that FIT the budget, the limits still guard runaway - // allocations mid-decode. TIFF additionally stacks its own intermediate - // scratch buffers on top of the reserve (empirically ~3x the frame size - // in image 0.25), so refusing an in-budget TIFF at face value would - // produce a note claiming e.g. "400 MB, over the 512 MB limit". - let tiff = matches!(reader.format(), Some(image::ImageFormat::Tiff)); + // allocations mid-decode. GIF is special: the probe reads the LOGICAL + // SCREEN, but a crafted frame may declare itself arbitrarily larger + // and the gif crate does not enforce frame-vs-screen consistency — so + // the frame buffer is capped to the probed size plus slack, refusing + // frame bombs into the existing Refused path. TIFF stacks its own + // intermediate scratch on top of the reserve (~3x worst case in image + // 0.25); the tier already folded that factor in, and the allowance + // matches it so an in-tier TIFF never codec-refuses. let mut limits = image::Limits::default(); - limits.max_alloc = Some(if tiff { - max_decode_bytes.saturating_mul(3) - } else { - max_decode_bytes + limits.max_alloc = Some(match reader.format() { + Some(image::ImageFormat::Gif) => probed + .map(|(width, height, _, _)| { + (width as u64).saturating_mul(height as u64).saturating_mul(4) + 64 * 1024 * 1024 + }) + .unwrap_or(max_decode_bytes) + .min(max_decode_bytes), + Some(image::ImageFormat::Tiff) => max_decode_bytes.saturating_mul(3), + _ => max_decode_bytes, }); reader.limits(limits); let img = match reader.decode() { Ok(img) => img, Err(image::ImageError::Limits(_)) => { return DecodeAttempt::Refused { - width: probed.map_or(0, |(width, _)| width), - height: probed.map_or(0, |(_, height)| height), + width: probed.map_or(0, |(width, _, _, _)| width), + height: probed.map_or(0, |(_, height, _, _)| height), }; } Err(_) => return DecodeAttempt::Failed, @@ -834,7 +956,7 @@ fn decode_with_budgets( let (stored_w, stored_h) = rgba.dimensions(); // Sizing truth: the file's probed dimensions, not the (possibly reduced) // buffer's, so placement and aspect come from the original raster. - let (source_width, source_height) = probed.unwrap_or((stored_w, stored_h)); + let (source_width, source_height) = probed.map_or((stored_w, stored_h), |(w, h, _, _)| (w, h)); DecodeAttempt::Decoded(DecodedImage { pixels: Arc::new(rgba.into_raw()), width: stored_w, @@ -873,6 +995,16 @@ fn refusal_message(path: &str, width: u32, height: u32) -> String { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()); + if width == 0 || height == 0 { + // The probe could not determine a size (exotic header) — do not + // print a self-contradictory "0x0 px" figure. + return crate::tf!( + "\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached.", + name = name, + limit = MAX_DECODE_BYTES / (1024 * 1024) + ) + .into_owned(); + } let decoded_mb = (width as u64).saturating_mul(height as u64).saturating_mul(4) / (1024 * 1024); let limit_mb = MAX_DECODE_BYTES / (1024 * 1024); crate::tf!( @@ -1029,11 +1161,32 @@ mod web_fetch { web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str( "ocs: remote-image outcome queue full; dropping an outcome", )); + // The dropped outcome may be a Loaded whose rebuild trigger is + // lost; remember that so the next poll does one unconditional + // image re-resolution instead of waiting for another event. + *overflowed() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = true; return; } queue.push_back(outcome); } + /// Whether an outcome was dropped at the cap and a catch-up rebuild is + /// owed. [`take_overflowed`] consumes it. + fn overflowed() -> &'static Mutex { + static FLAG: OnceLock> = OnceLock::new(); + FLAG.get_or_init(|| Mutex::new(false)) + } + + /// Consume the dropped-outcome flag (see [`overflowed`]). + pub fn take_overflowed() -> bool { + let mut flag = overflowed() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + std::mem::replace(&mut *flag, false) + } + /// Pop the oldest settled remote fetch the poll has not handled yet. pub fn take_outcome() -> Option { outcomes() @@ -1054,6 +1207,58 @@ mod web_fetch { } } + /// Read a response body incrementally, refusing past [`MAX_BYTES`] the + /// moment the cap is crossed (not after buffering the whole body). + async fn stream_body_capped( + window: &web_sys::Window, + response: &web_sys::Response, + ) -> Result, RemoteFetchError> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let Some(stream) = response.body() else { + // No stream API (or already consumed): fall back to buffering, + // with the received-length check still applying after. + let buffer = JsFuture::from( + response.array_buffer().map_err(|_| RemoteFetchError::Network)?, + ) + .await + .map_err(|error| rejection_kind(&error))?; + let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); + if bytes.len() as u64 > MAX_BYTES { + return Err(RemoteFetchError::TooLarge); + } + return Ok(bytes); + }; + let reader = stream.get_reader().dyn_into::() + .map_err(|_| RemoteFetchError::Network)?; + let mut bytes: Vec = Vec::new(); + loop { + let chunk = JsFuture::from(reader.read()) + .await + .map_err(|error| rejection_kind(&error))?; + let done = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("done")) + .ok() + .and_then(|value| value.as_bool()) + .unwrap_or(true); + if done { + break; + } + let value = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("value")) + .map_err(|_| RemoteFetchError::Network)?; + let view = js_sys::Uint8Array::new(&value); + if bytes.len() as u64 + view.length() as u64 > MAX_BYTES { + // Release the reader lock so the underlying connection can + // be torn down, then refuse. + let _ = JsFuture::from(reader.cancel()).await; + return Err(RemoteFetchError::TooLarge); + } + bytes.extend_from_slice(&view.to_vec()); + } + let _ = window; // reader holds the stream; window kept for symmetry + Ok(bytes) + } + /// The actual request. `GET url` with an abort signal armed at /// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the /// declared `Content-Length` first and the received length second. @@ -1092,8 +1297,9 @@ mod web_fetch { return Err(RemoteFetchError::Status(response.status())); } // Pre-check the declared size so an oversized body is refused before - // it is buffered; the received length is checked again after the - // read (chunked replies carry no Content-Length). + // it is buffered; then STREAM the body, aborting the moment the + // received bytes pass MAX_BYTES — array_buffer() would materialize + // a hostile chunked reply in full before any check could fire. let declared = response .headers() .get("Content-Length") @@ -1103,18 +1309,7 @@ mod web_fetch { if declared.is_some_and(|declared| declared > MAX_BYTES) { return Err(RemoteFetchError::TooLarge); } - let buffer = JsFuture::from( - response - .array_buffer() - .map_err(|_| RemoteFetchError::Network)?, - ) - .await - .map_err(|error| rejection_kind(&error))?; - let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); - if bytes.len() as u64 > MAX_BYTES { - return Err(RemoteFetchError::TooLarge); - } - Ok(bytes) + stream_body_capped(&window, &response).await } /// Start the background fetch for `url` if one hasn't been attempted this @@ -1155,7 +1350,8 @@ fn fetch_remote(url: &str) -> Option> { #[cfg(target_arch = "wasm32")] pub use web_fetch::{ - take_outcome as take_remote_fetch_outcome, RemoteFetchError, RemoteFetchOutcome, + take_outcome as take_remote_fetch_outcome, take_overflowed, + RemoteFetchError, RemoteFetchOutcome, }; #[cfg(all(test, not(target_arch = "wasm32")))]