forked from 1b-agent/opencadstudio-upstream
fix: native-bpp decode accounting, GIF frame bombs, streaming remote bodies
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.
This commit is contained in:
1 parent
58d3789bcf
commit
c6db94e86f
27 files changed
+284
-50
No files matched your search
@@ -159,6 +159,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"] }
|
||||
|
||||
|
||||
@@ -3589,6 +3589,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة...
|
||||
.opening-ocs-web = يجري فتح OCS Web...
|
||||
.opening-the-command-reference = فتح مرجع الأوامر...
|
||||
.image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود.
|
||||
.opening-patreon-page = يجري فتح صفحة Patreon...
|
||||
.opening-feedback-page = يجري فتح صفحة الملاحظات...
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси...
|
||||
.opening-ocs-web = Отваряне на OCS уебсайта...
|
||||
.opening-the-command-reference = Отваряне на справочника за команди...
|
||||
.image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен.
|
||||
.opening-patreon-page = Отваряне на страницата в Patreon...
|
||||
.opening-feedback-page = Отваряне на страницата за обратна връзка...
|
||||
|
||||
@@ -3574,6 +3574,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy...
|
||||
.opening-ocs-web = Otevírání webu OCS...
|
||||
.opening-the-command-reference = Otevírání referenční příručky příkazů...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován.
|
||||
.opening-patreon-page = Otevírání stránky Patreon...
|
||||
.opening-feedback-page = Otevírá se stránka zpětné vazby...
|
||||
|
||||
@@ -3575,6 +3575,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen...
|
||||
.opening-ocs-web = Öffnen OCS Web...
|
||||
.opening-the-command-reference = Öffnen der Befehlsreferenz...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten.
|
||||
.opening-patreon-page = Eröffnungsseite Patreon...
|
||||
.opening-feedback-page = Feedbackseite öffnen...
|
||||
|
||||
@@ -3571,6 +3571,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις...
|
||||
.opening-ocs-web = Άνοιγμα του OCS Web...
|
||||
.opening-the-command-reference = Άνοιγμα της αναφοράς εντολών...
|
||||
.image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί.
|
||||
.already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο.
|
||||
.opening-patreon-page = Άνοιγμα της σελίδας Patreon...
|
||||
.opening-feedback-page = Άνοιγμα της σελίδας σχολίων...
|
||||
|
||||
@@ -3572,6 +3572,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions...
|
||||
.opening-ocs-web = Opening OCS Web...
|
||||
.opening-the-command-reference = Opening the command reference...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept.
|
||||
.opening-patreon-page = Opening Patreon page...
|
||||
.opening-feedback-page = Opening feedback page...
|
||||
|
||||
@@ -3577,6 +3577,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas...
|
||||
.opening-ocs-web = Abriendo OCS Web...
|
||||
.opening-the-command-reference = Abriendo la referencia de comandos...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente.
|
||||
.opening-patreon-page = Abriendo la página de Patreon...
|
||||
.opening-feedback-page = Abriendo la página de comentarios...
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten...
|
||||
.opening-ocs-web = Avataan OCS Web...
|
||||
.opening-the-command-reference = Avataan komentoviittausta...
|
||||
.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ää.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin.
|
||||
.opening-patreon-page = Avataan Patreon-sivua...
|
||||
.opening-feedback-page = Palautesivua avataan...
|
||||
|
||||
@@ -3577,6 +3577,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions...
|
||||
.opening-ocs-web = Ouverture du site Web OCS...
|
||||
.opening-the-command-reference = Ouverture de la référence des commandes...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé.
|
||||
.opening-patreon-page = Ouverture de la page Patreon...
|
||||
.opening-feedback-page = Ouverture de la page de rétroaction...
|
||||
|
||||
@@ -3565,6 +3565,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना।
|
||||
.opening-ocs-web = OCS वेब खोलना ...
|
||||
.opening-the-command-reference = कमांड संदर्भ खोला जा रहा है...
|
||||
.image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता।
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई।
|
||||
.opening-patreon-page = Patreon पृष्ठ खोला जा रहा है…
|
||||
.opening-feedback-page = प्रतिक्रिया पृष्ठ खोलना
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért...
|
||||
.opening-ocs-web = A OCS web megnyitása...
|
||||
.opening-the-command-reference = A parancsreferencia megnyitása...
|
||||
.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ó.
|
||||
.already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve.
|
||||
.opening-patreon-page = Patreon oldal megnyitása...
|
||||
.opening-feedback-page = Visszajelzési oldal megnyitása...
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande...
|
||||
.opening-ocs-web = Apertura del Web OCS...
|
||||
.opening-the-command-reference = Apertura del riferimento dei comandi...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto.
|
||||
.opening-patreon-page = Apertura della pagina Patreon...
|
||||
.opening-feedback-page = Apertura della pagina di feedback...
|
||||
|
||||
@@ -3565,6 +3565,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています...
|
||||
.opening-ocs-web = OCS Web を開いています...
|
||||
.opening-the-command-reference = コマンドリファレンスを開いています...
|
||||
.image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。
|
||||
.opening-patreon-page = Patreon ページを開いています...
|
||||
.opening-feedback-page = フィードバックページを開いています...
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론...
|
||||
.opening-ocs-web = OCS 웹을 여는 중...
|
||||
.opening-the-command-reference = 명령 참조 열기...
|
||||
.image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다.
|
||||
.opening-patreon-page = Patreon 페이지를 여는 중...
|
||||
.opening-feedback-page = 피드백 페이지를 여는 중...
|
||||
|
||||
@@ -3575,6 +3575,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen...
|
||||
.opening-ocs-web = Openen OCS Web...
|
||||
.opening-the-command-reference = Commandoreferentie openen...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard.
|
||||
.opening-patreon-page = Patreon pagina openen...
|
||||
.opening-feedback-page = Herinneringspagina openen...
|
||||
|
||||
@@ -3576,6 +3576,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań...
|
||||
.opening-ocs-web = Otwieranie OCS Web...
|
||||
.opening-the-command-reference = Otwieranie dokumentacji poleceń...
|
||||
.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ć.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik.
|
||||
.opening-patreon-page = Otwieranie strony Patreon...
|
||||
.opening-feedback-page = Otwieram stronę z opiniami...
|
||||
|
||||
@@ -3577,6 +3577,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas...
|
||||
.opening-ocs-web = Abrindo OCS Web...
|
||||
.opening-the-command-reference = Abrindo a referência de comandos...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido.
|
||||
.opening-patreon-page = Abrindo página do Patreon...
|
||||
.opening-feedback-page = Abrindo página de comentários...
|
||||
|
||||
@@ -3578,6 +3578,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах
|
||||
.opening-ocs-web = Открытие OCS Веб-сайт
|
||||
.opening-the-command-reference = Открытие справочника команд...
|
||||
.image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён.
|
||||
.opening-patreon-page = Открытие страницы Patreon...
|
||||
.opening-feedback-page = Открываем страницу обратной связи...
|
||||
|
||||
@@ -3527,6 +3527,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor...
|
||||
.opening-ocs-web = OCS Web açılıyor...
|
||||
.opening-the-command-reference = Komut başvurusu açılıyor...
|
||||
.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.
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu.
|
||||
.opening-patreon-page = Patreon sayfası açılıyor...
|
||||
.opening-feedback-page = Geri bildirim sayfası açılıyor...
|
||||
|
||||
@@ -3560,6 +3560,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题...
|
||||
.opening-ocs-web = 打开OCS网络...
|
||||
.opening-the-command-reference = 正在打开命令参考...
|
||||
.image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。
|
||||
.opening-patreon-page = 打开帕特伦的页面...
|
||||
.opening-feedback-page = 打开反馈页面...
|
||||
|
||||
@@ -3572,6 +3572,7 @@ view =
|
||||
.opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題...
|
||||
.opening-ocs-web = 開啟OCS網路...
|
||||
.opening-the-command-reference = 正在開啟命令參考...
|
||||
.image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。
|
||||
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。
|
||||
.opening-patreon-page = 開啟帕特倫的頁面...
|
||||
.opening-feedback-page = 開啟反饋頁面...
|
||||
|
||||
@@ -28,6 +28,12 @@ trunk build --release --dist dist/app --public-url /app/ "$@"
|
||||
|
||||
# Mirror the font sources over the bundle and verify the copy-dir output.
|
||||
mkdir -p dist/app/fonts
|
||||
# Hard-fail with a clear message when there are no fonts to verify.
|
||||
first_font=$(echo web/fonts/*.ttf)
|
||||
if [ ! -e "$first_font" ]; then
|
||||
echo "build-web.sh: no .ttf fonts found under web/fonts — nothing to ship or verify" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -f web/fonts/* dist/app/fonts/
|
||||
|
||||
count=0
|
||||
|
||||
@@ -448,6 +448,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);
|
||||
|
||||
+1
-1
@@ -1905,7 +1905,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}\""))
|
||||
})?;
|
||||
|
||||
@@ -1886,6 +1886,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")),
|
||||
"Opening the command reference..." => Some(("view", "opening-the-command-reference")),
|
||||
"\"{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")),
|
||||
"\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")),
|
||||
"Opening Patreon page..." => Some(("view", "opening-patreon-page")),
|
||||
"Opening feedback page..." => Some(("view", "opening-feedback-page")),
|
||||
|
||||
+245
-49
@@ -135,6 +135,19 @@ impl ImageModel {
|
||||
pub fn from_raster_image(img: &acadrust::entities::RasterImage) -> Option<Self> {
|
||||
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: &acadrust::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.
|
||||
@@ -396,6 +409,14 @@ fn image_cache() -> &'static Mutex<HashMap<String, ImageCacheEntry>> {
|
||||
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<Vec<String>> {
|
||||
static ORDER: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
ORDER.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
|
||||
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
|
||||
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
@@ -428,10 +449,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<Vec<u8>>) {
|
||||
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));
|
||||
}
|
||||
@@ -466,10 +495,17 @@ fn registered_bytes_by_name(path: &str) -> Option<Arc<Vec<u8>>> {
|
||||
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
|
||||
@@ -593,12 +629,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<image::ImageFormat>, color: Option<image::ColorType>) -> 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.
|
||||
@@ -607,9 +682,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
|
||||
@@ -658,24 +751,45 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -684,24 +798,32 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
||||
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,
|
||||
@@ -726,7 +848,7 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
|
||||
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,
|
||||
@@ -765,6 +887,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!(
|
||||
@@ -921,11 +1053,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<bool> {
|
||||
static FLAG: OnceLock<Mutex<bool>> = 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<RemoteFetchOutcome> {
|
||||
outcomes()
|
||||
@@ -946,6 +1099,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<Vec<u8>, 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::<web_sys::ReadableStreamDefaultReader>()
|
||||
.map_err(|_| RemoteFetchError::Network)?;
|
||||
let mut bytes: Vec<u8> = 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.
|
||||
@@ -984,8 +1189,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")
|
||||
@@ -995,18 +1201,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
|
||||
@@ -1047,7 +1242,8 @@ fn fetch_remote(url: &str) -> Option<Vec<u8>> {
|
||||
|
||||
#[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")))]
|
||||
|
||||
Reference in new issue
Block a user