From e081cec771d1cec9712febf8bc531acaa22d6570 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 08:51:13 +0900 Subject: [PATCH 1/2] Revert "Merge pull request #686 from KnifeLemon/engine/resource-limits" This reverts commit a34e7478885ba2117247f5bc9380b14068f4e703, reversing changes made to 8db117a0d81d27f6bfe969a64a7ddd628cd5b76f. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/fuzz.yml | 58 -- docs/ENGINES.md | 28 +- src/Filee.App/Assets/i18n/ar.json | 1 - src/Filee.App/Assets/i18n/de.json | 1 - src/Filee.App/Assets/i18n/en.json | 1 - src/Filee.App/Assets/i18n/es.json | 1 - src/Filee.App/Assets/i18n/fr.json | 1 - src/Filee.App/Assets/i18n/hi.json | 1 - src/Filee.App/Assets/i18n/ja.json | 1 - src/Filee.App/Assets/i18n/ko.json | 1 - src/Filee.App/Assets/i18n/ne.json | 1 - src/Filee.App/Assets/i18n/pl.json | 1 - src/Filee.App/Assets/i18n/pt-BR.json | 1 - src/Filee.App/Assets/i18n/pt-PT.json | 1 - src/Filee.App/Assets/i18n/ru.json | 1 - src/Filee.App/Assets/i18n/th.json | 1 - src/Filee.App/Assets/i18n/tr.json | 1 - src/Filee.App/Assets/i18n/zh-CN.json | 1 - src/Filee.App/Assets/i18n/zh-TW.json | 1 - src/Filee.Engines/Documents/DocumentBudget.cs | 58 -- src/Filee.Engines/Documents/DocumentLimits.cs | 96 --- .../Documents/DocumentPackage.cs | 240 ------- src/Filee.Engines/Documents/LayoutGuard.cs | 71 -- src/Filee.Engines/Documents/PictureSize.cs | 146 ---- src/Filee.Engines/Geo/KmlFormat.cs | 61 +- .../{Infrastructure => Geo}/LimitedStream.cs | 34 +- .../Hwp/Hwpx/Docx/DocxReader.Drawing.cs | 15 +- .../Hwp/Hwpx/Docx/DocxReader.Styles.cs | 28 +- .../Hwp/Hwpx/Docx/DocxReader.Tables.cs | 14 +- src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.cs | 85 +-- src/Filee.Engines/Hwp/Hwpx/HwpxReader.Body.cs | 34 +- .../Hwp/Hwpx/HwpxReader.Objects.cs | 2 - src/Filee.Engines/Hwp/Hwpx/HwpxReader.cs | 62 +- src/Filee.Engines/Hwp/Hwpx/HwpxUnits.cs | 5 +- src/Filee.Engines/Hwp/Hwpx/StyleResolver.cs | 19 +- src/Filee.Engines/Infrastructure/SafeXml.cs | 130 ---- src/Filee.Engines/Ofd/OfdPackage.cs | 89 ++- src/Filee.Engines/Ofd/OfdReader.cs | 4 - .../Filee.Engines.Tests/DocumentFuzzTests.cs | 233 ------- tests/Filee.Engines.Tests/DocumentFuzzer.cs | 375 ----------- .../DocumentHostileInputTests.cs | 637 ------------------ .../GeoHostileInputTests.cs | 1 - tests/Filee.Engines.Tests/HwpxReaderTests.cs | 2 +- 43 files changed, 216 insertions(+), 2328 deletions(-) delete mode 100644 .github/workflows/fuzz.yml delete mode 100644 src/Filee.Engines/Documents/DocumentBudget.cs delete mode 100644 src/Filee.Engines/Documents/DocumentLimits.cs delete mode 100644 src/Filee.Engines/Documents/DocumentPackage.cs delete mode 100644 src/Filee.Engines/Documents/LayoutGuard.cs delete mode 100644 src/Filee.Engines/Documents/PictureSize.cs rename src/Filee.Engines/{Infrastructure => Geo}/LimitedStream.cs (51%) delete mode 100644 src/Filee.Engines/Infrastructure/SafeXml.cs delete mode 100644 tests/Filee.Engines.Tests/DocumentFuzzTests.cs delete mode 100644 tests/Filee.Engines.Tests/DocumentFuzzer.cs delete mode 100644 tests/Filee.Engines.Tests/DocumentHostileInputTests.cs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml deleted file mode 100644 index caa5c0ca3..000000000 --- a/.github/workflows/fuzz.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Nightly mutation fuzzing of the document readers (tests/Filee.Engines.Tests/DocumentFuzzTests.cs): each reader gets a -# few minutes of inputs changed from seeds written in code, with a new random seed every night. Pull requests run the -# same tests briefly with a fixed seed as part of the normal test run; this job only adds the long run. A failure -# names the seed (FILEE_FUZZ_SEED repeats it) and uploads the inputs that failed. -name: Fuzz - -on: - schedule: - - cron: "17 18 * * *" # 03:17 in Korea - workflow_dispatch: - inputs: - seconds: - description: Seconds per reader - default: "180" - seed: - description: Seed to repeat (empty for a new one) - default: "" - -permissions: - contents: read - -concurrency: - group: fuzz - cancel-in-progress: true - -jobs: - fuzz: - runs-on: ubuntu-24.04 - timeout-minutes: 25 - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - - - name: Build - run: dotnet build tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj -c Release - - - name: Fuzz the document readers - env: - FILEE_FUZZ_SECONDS: ${{ inputs.seconds || '180' }} - FILEE_FUZZ_SEED: ${{ inputs.seed }} - FILEE_FUZZ_OUT: ${{ runner.temp }}/fuzz-failures - run: | - mkdir -p "$FILEE_FUZZ_OUT" - dotnet test tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj --no-build -c Release --filter "Category=Fuzz" \ - --blame-crash --results-directory TestResults --logger "console;verbosity=detailed" - - - name: Upload the inputs that failed - if: failure() - uses: actions/upload-artifact@v7 - with: - name: fuzz-failures - path: | - ${{ runner.temp }}/fuzz-failures/ - TestResults/ - if-no-files-found: ignore diff --git a/docs/ENGINES.md b/docs/ENGINES.md index bf918f9f8..e2b6f68e1 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -628,8 +628,7 @@ sits on the office donut (settings schema 27 teaches existing donuts `.ofd`). resource files (PublicRes, DocumentRes, a page's PageRes, and the unnamed `Res.xml` WPS writes next to them). Parts are looked up by name inside the ZIP and never written out under their own names; references are resolved against the referring part (or the package root for "/…"), "." and ".." applied, and anything that leaves the - package, a URL or a drive path finds nothing. The package is read like HWPX and DOCX (`Documents/DocumentPackage.cs`, - see [HWPX reader](#hwpx-reader)): XML without DTDs; a part may unpack to 64 MB, a picture to + package, a URL or a drive path finds nothing. XML is read without DTDs; a part may unpack to 64 MB, a picture to 32 MB (larger ones are left out), the whole package to 1 GB; at most 100,000 parts, 10,000 pages, 2,000,000 drawing objects, page blocks nested 32 deep. DeltaX / DeltaY "g n d" repeats never yield more values than the text has characters. @@ -1235,31 +1234,6 @@ OLE objects, charts, video, form controls, text art (its text is kept), arcs / p master pages, memos, character ratio, relative size and offset, paragraph borders, picture cropping and rotation. Password-protected (encrypted) HWPX and DRM-wrapped files stop with a clear error. -**Limits.** Untrusted documents must not hang, exhaust the memory or crash Filee. The HWPX, DOCX and OFD readers (and -the HWP 5.0 reader and the layout engine when they come) share `Documents/DocumentLimits.cs`; a document that passes -one stops with `error.document_too_complex` and says which: - -- the package (`Documents/DocumentPackage.cs`): at most 100,000 parts; a part may unpack to 64 MB, all parts together - to 1 GB, counted while they unpack (a ZIP header can understate a size), and a part over 4 MB to no more than 200 - times its packed size; parts are found by name inside the package only, and extracted pictures get names Filee - makes (`hwpx-media-1.png`), never the part's; -- XML (`Infrastructure/SafeXml.cs`, also used for KML and GPX): no DTD and no resolver, so no entity expands and - nothing outside the package is read; at most 512 levels deep and 5,000,000 elements in a package; -- the document (`Documents/DocumentBudget.cs`): tables in tables, text boxes, notes, groups and fields nest at most 32 - levels; at most 1,000,000 paragraphs and 200,000 tables, pictures and drawing objects; table columns, spans and - row spans are cut to the table (at most 16,384 columns); Word style chains are followed 20 styles deep; -- pictures (`Documents/PictureSize.cs`): one whose PNG, JPEG, GIF, BMP or WebP header claims more than - 16,384 × 16,384 pixels is left out before anything decodes it; -- layout (`Documents/LayoutGuard.cs`, for the layout engine): at most 20,000 pages, 64 empty pages in a row, ten minutes; - cancellation and the time are checked per paragraph, and an object larger than the page is clipped and placed. - -Embedded fonts (DOCX `word/fonts`, HWPX and OFD font files) are not read; a reader that uses them must pass them -through Filee's SFNT parser (`Fonts/SfntFont.cs`) first. `DocumentHostileInputTests` holds ZIP bombs, deep nesting, -DTDs and huge pictures; `DocumentFuzzTests` changes packages written in code at random (attributes set to extreme -values, elements removed, repeated, renamed or nested, text and bytes damaged) and allows a reader only to read a -file or refuse it with `InvalidDataException` / `ConversionRefusedException`. It runs briefly with every test run and -for a few minutes per reader every night (`.github/workflows/fuzz.yml`); `FILEE_FUZZ_SEED` repeats a run. - ## E-books The built-in e-book engine (`src/Filee.Engines/Ebooks`) reads books into the same document model as the HWPX diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 384a2e147..2e42b0672 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "هذا النوع من خطوط ‎.fon غير مدعوم", "error.fon_too_large": "خط ‎.fon كبير جدًا بحيث لا يمكن تحويله", "error.fon_damaged": "ملف ‎.fon تالف أو غير مكتمل", - "error.document_too_complex": "المستند كبير جدًا أو معقّد جدًا بحيث لا يمكن قراءته بأمان", "error.m4s_needs_init": "ملف ‎.m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6b0f62eac..9ff6b6d66 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Diese Art von .fon-Schrift wird nicht unterstützt", "error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren", "error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig", - "error.document_too_complex": "Das Dokument ist zu groß oder zu komplex, um es sicher zu lesen", "error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen", "notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert", "notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index bf28b748e..5d09bbf75 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "This kind of .fon font isn't supported", "error.fon_too_large": "The .fon font is too large to convert", "error.fon_damaged": "The .fon file is damaged or cut short", - "error.document_too_complex": "The document is too large or too complex to read safely", "error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 7ef306f53..048ceac5d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Este tipo de fuente .fon no es compatible", "error.fon_too_large": "La fuente .fon es demasiado grande para convertirla", "error.fon_damaged": "El archivo .fon está dañado o incompleto", - "error.document_too_complex": "El documento es demasiado grande o complejo para leerlo de forma segura", "error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index f329c4f4b..7b517140f 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Ce type de police .fon n'est pas pris en charge", "error.fon_too_large": "La police .fon est trop grande pour être convertie", "error.fon_damaged": "Le fichier .fon est endommagé ou incomplet", - "error.document_too_complex": "Le document est trop volumineux ou trop complexe pour être lu en toute sécurité", "error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 6d87f7cb2..e44149bfe 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "इस तरह का .fon फ़ॉन्ट समर्थित नहीं है", "error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है", "error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है", - "error.document_too_complex": "दस्तावेज़ इतना बड़ा या जटिल है कि उसे सुरक्षित रूप से पढ़ा नहीं जा सकता", "error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 547b261ba..cc0d2b3e3 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "この種類の.fonフォントには対応していません", "error.fon_too_large": ".fonフォントが大きすぎて変換できません", "error.fon_damaged": ".fonファイルが破損しているか、途中で切れています", - "error.document_too_complex": "文書が大きすぎるか複雑すぎるため、安全に読み込めません", "error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 2098d6a6e..d5f1e16e4 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "지원하지 않는 종류의 .fon 글꼴이에요", "error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요", "error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요", - "error.document_too_complex": "문서가 너무 크거나 복잡해서 안전하게 읽을 수 없어요", "error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0a38f331d..5e8926eee 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "यस प्रकारको .fon फन्ट समर्थित छैन", "error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ", "error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ", - "error.document_too_complex": "कागजात सुरक्षित रूपमा पढ्न नसकिने गरी धेरै ठूलो वा जटिल छ", "error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index c4e18c3c5..8f50edccb 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Ten rodzaj czcionki .fon nie jest obsługiwany", "error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować", "error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny", - "error.document_too_complex": "Dokument jest zbyt duży lub zbyt złożony, aby bezpiecznie go odczytać", "error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 96fefb715..9b96f02f8 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é compatível", "error.fon_too_large": "A fonte .fon é grande demais para converter", "error.fon_damaged": "O arquivo .fon está danificado ou incompleto", - "error.document_too_complex": "O documento é grande ou complexo demais para ser lido com segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index d9784665d..41c27c756 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é suportado", "error.fon_too_large": "A fonte .fon é demasiado grande para converter", "error.fon_damaged": "O ficheiro .fon está danificado ou incompleto", - "error.document_too_complex": "O documento é demasiado grande ou complexo para ser lido em segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado", "notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações", "notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index e7548813f..e1a24eb79 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Этот тип шрифта .fon не поддерживается", "error.fon_too_large": "Шрифт .fon слишком велик для преобразования", "error.fon_damaged": "Файл .fon повреждён или обрезан", - "error.document_too_complex": "Документ слишком большой или сложный, чтобы безопасно его прочитать", "error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 12e899c40..536d044e9 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "ไม่รองรับฟอนต์ .fon ประเภทนี้", "error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้", "error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์", - "error.document_too_complex": "เอกสารใหญ่หรือซับซ้อนเกินกว่าจะอ่านได้อย่างปลอดภัย", "error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 96f6dc4e8..449f8b300 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Bu tür .fon yazı tipi desteklenmiyor", "error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük", "error.fon_damaged": ".fon dosyası hasarlı veya eksik", - "error.document_too_complex": "Belge güvenli şekilde okunamayacak kadar büyük veya karmaşık", "error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz", "notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı", "notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 46155fad3..8d069c66b 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "不支持此类 .fon 字体", "error.fon_too_large": ".fon 字体太大,无法转换", "error.fon_damaged": ".fon 文件已损坏或不完整", - "error.document_too_complex": "文档过大或过于复杂,无法安全读取", "error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取", "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 3b8018054..5d5551093 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "不支援此類 .fon 字型", "error.fon_too_large": ".fon 字型太大,無法轉換", "error.fon_damaged": ".fon 檔案已損壞或不完整", - "error.document_too_complex": "文件過大或過於複雜,無法安全讀取", "error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)", diff --git a/src/Filee.Engines/Documents/DocumentBudget.cs b/src/Filee.Engines/Documents/DocumentBudget.cs deleted file mode 100644 index 631f19cb8..000000000 --- a/src/Filee.Engines/Documents/DocumentBudget.cs +++ /dev/null @@ -1,58 +0,0 @@ -// What one document reader may still build: nesting (tables in tables, text boxes, notes, groups, fields) and the -// number of paragraphs and objects. Readers call it where they recurse or add to the model, so a small file that -// expands into millions of paragraphs, or nests deeply enough to overflow the stack in a writer, is refused. - -namespace Filee.Engines.Documents; - -/// Counts what a reader builds against . -internal sealed class DocumentBudget(DocumentLimits limits) -{ - private int _depth; - private int _paragraphs; - private int _objects; - - public DocumentLimits Limits { get; } = limits; - - /// Current nesting depth. - public int Depth => _depth; - - public int Paragraphs => _paragraphs; - - public int Objects => _objects; - - /// Enters a nested level (a table cell, text box, note, group); dispose the result to leave it. - public Level Enter() - { - if (_depth >= Limits.MaxNestingDepth) - throw DocumentLimits.Exceeded($"Tables, text boxes, notes or groups are nested more than {Limits.MaxNestingDepth} levels deep."); - _depth++; - return new Level(this); - } - - /// Checks a count of open fields (fields inside fields). - public void Fields(int open) - { - if (open > Limits.MaxNestingDepth) - throw DocumentLimits.Exceeded($"Fields are nested more than {Limits.MaxNestingDepth} levels deep."); - } - - /// Counts a paragraph. - public void Paragraph() - { - if (++_paragraphs > Limits.MaxParagraphs) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxParagraphs)} paragraphs."); - } - - /// Counts a table, picture, shape, text box or equation. - public void Object() - { - if (++_objects > Limits.MaxObjects) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxObjects)} tables, pictures and drawing objects."); - } - - /// A nested level; leaves it when disposed. - public readonly struct Level(DocumentBudget budget) : IDisposable - { - public void Dispose() => budget._depth--; - } -} diff --git a/src/Filee.Engines/Documents/DocumentLimits.cs b/src/Filee.Engines/Documents/DocumentLimits.cs deleted file mode 100644 index 892cf8e07..000000000 --- a/src/Filee.Engines/Documents/DocumentLimits.cs +++ /dev/null @@ -1,96 +0,0 @@ -// How much of an untrusted document Filee reads and lays out, shared by the document readers (HWPX, DOCX, OFD, and -// the HWP 5.0 reader when it comes) and the layout engine, so a hostile file can't hang, exhaust the memory or crash -// Filee. Every limit is far above what real documents need: a document that passes one is refused with -// error.document_too_complex rather than read in part. -// -// Where they apply: -// * DocumentPackage ZIP packages: entries, bytes per part and in total (counted while unpacking, so a header that -// understates a part does not help), the inflate ratio, XML depth and elements (SafeXml). -// * DocumentBudget readers: nesting of tables, text boxes, notes, groups and fields; paragraphs and objects. -// * PictureSize pictures whose header claims more than MaxPicturePixels are left out. -// * LayoutGuard the layout loop: pages, empty pages in a row, the time budget and cancellation. -// A compound file (CFB) reader adds its stream sizes to MaxPartBytes / MaxPackageBytes and must check sector chains -// for cycles itself. -// -// Embedded fonts are not read by any reader (DOCX odttf parts, HWPX and OFD font files keep only their names). A -// reader that ever uses them must pass them through Filee's own SFNT parser (Fonts/SfntFont) before Skia or HarfBuzz. - -using System.Globalization; -using Filee.Core.Conversion; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Documents; - -/// Limits for reading and laying out one document. -internal sealed record DocumentLimits -{ - /// The limits Filee uses. - public static DocumentLimits Default { get; } = new(); - - /// The error key of a document refused for passing a limit. - public const string ErrorKey = "error.document_too_complex"; - - /// Bytes one part of a package (an XML part, a picture) may unpack to. - public long MaxPartBytes { get; init; } = 64L * 1024 * 1024; - - /// Bytes all parts read from one package may unpack to together. - public long MaxPackageBytes { get; init; } = 1024L * 1024 * 1024; - - /// Entries in a package (real documents have a few per page or picture). - public int MaxPackageEntries { get; init; } = 100_000; - - /// - /// How many times its compressed size a part may unpack to, once it is larger than . - /// Deflate reaches about 1,000 on runs of one byte; XML from word processors stays far below 100. - /// - public int MaxInflateRatio { get; init; } = 200; - - /// What a part may always unpack to, whatever its ratio (small parts of repeated markup compress well). - public long InflateRatioFloor { get; init; } = 4L * 1024 * 1024; - - /// Levels of XML elements in a part. Tables nested deep stay well within. - public int MaxXmlDepth { get; init; } = 512; - - /// XML elements in all parts of a package together (each costs some 100 bytes of memory in the tree). - public int MaxXmlElements { get; init; } = 5_000_000; - - /// Levels of tables in tables, text boxes, notes, groups and fields. - public int MaxNestingDepth { get; init; } = 32; - - /// Paragraphs in a document (cells, notes, headers and text boxes included). - public int MaxParagraphs { get; init; } = 1_000_000; - - /// Tables, pictures, shapes, text boxes and equations in a document. - public int MaxObjects { get; init; } = 200_000; - - /// - /// Columns of a table (as many as a spreadsheet has). Column numbers and spans beyond are cut there, so a damaged - /// cell address can't make a table of two billion columns. - /// - public int MaxTableColumns { get; init; } = 16_384; - - /// Pages the layout engine makes of one document. - public int MaxPages { get; init; } = 20_000; - - /// Pixels of one picture (16,384 × 16,384, beyond every camera); bigger pictures are left out. - public long MaxPicturePixels { get; init; } = 16_384L * 16_384; - - /// Pages in a row with nothing placed on them before the layout counts as stuck. - public int MaxConsecutiveEmptyPages { get; init; } = 64; - - /// How long laying out one document may take. - public TimeSpan LayoutTimeBudget { get; init; } = TimeSpan.FromMinutes(10); - - /// The XML limits of one part, given how many elements the package's other parts held. - internal XmlLimits Xml(int elementsBefore, bool preserveWhitespace) => - new(MaxXmlDepth, MaxXmlElements, MaxPartBytes, preserveWhitespace, ElementsBefore: elementsBefore); - - /// The refusal for a document that passes a limit; says which (in English). - public static ConversionRefusedException Exceeded(string detail) => new(ErrorKey, detail); - - /// "64 MB" for messages. - internal static string Megabytes(long bytes) => (bytes / (1024 * 1024)).ToString("N0", CultureInfo.InvariantCulture) + " MB"; - - /// "20,000" for messages. - internal static string Count(long count) => count.ToString("N0", CultureInfo.InvariantCulture); -} diff --git a/src/Filee.Engines/Documents/DocumentPackage.cs b/src/Filee.Engines/Documents/DocumentPackage.cs deleted file mode 100644 index 0cccc7131..000000000 --- a/src/Filee.Engines/Documents/DocumentPackage.cs +++ /dev/null @@ -1,240 +0,0 @@ -// The ZIP package of a document (HWPX, DOCX, OFD), read within DocumentLimits: parts are looked up by name inside the -// package only, every part is counted while it is unpacked (its own size, the package's total and the inflate ratio), -// XML is read by SafeXml without DTDs or a resolver, and parts are only ever written out under names Filee makes. -// A damaged package fails with InvalidDataException, one that passes a limit with error.document_too_complex. - -using System.IO.Compression; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Documents; - -/// A document package opened for reading. -internal sealed class DocumentPackage : IDisposable -{ - private readonly ZipArchive _zip; - private long _total; - private int _elements; - - private DocumentPackage(ZipArchive zip, DocumentLimits limits) - { - _zip = zip; - Limits = limits; - } - - public DocumentLimits Limits { get; } - - /// Bytes unpacked so far. - public long BytesRead => _total; - - /// XML elements read so far. - public int ElementsRead => _elements; - - public IReadOnlyCollection Entries => _zip.Entries; - - /// Opens the package at ; makes the error for a file that is no ZIP. - public static DocumentPackage Open(string path, DocumentLimits? limits = null, Func? notAPackage = null) => - Open(() => ZipFile.OpenRead(path), limits ?? DocumentLimits.Default, notAPackage); - - /// A package in a stream (left open). - public static DocumentPackage Open(Stream stream, DocumentLimits? limits = null, Func? notAPackage = null) => - Open(() => new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true), limits ?? DocumentLimits.Default, notAPackage); - - private static DocumentPackage Open(Func open, DocumentLimits limits, Func? notAPackage) - { - ZipArchive zip; - try - { - zip = open(); - } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or NotSupportedException - || ex is IOException and not (FileNotFoundException or DirectoryNotFoundException)) - { - throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); - } - try - { - // The central directory is read when the entries are first listed. - if (zip.Entries.Count > limits.MaxPackageEntries) - throw DocumentLimits.Exceeded($"The package has more than {DocumentLimits.Count(limits.MaxPackageEntries)} parts."); - } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or IOException) - { - zip.Dispose(); - throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); - } - catch - { - zip.Dispose(); - throw; - } - return new DocumentPackage(zip, limits); - } - - public void Dispose() => _zip.Dispose(); - - /// The entry of that exact name; null when there is none. - public ZipArchiveEntry? GetEntry(string name) - { - try - { - return name.Length == 0 ? null : _zip.GetEntry(name); - } - catch (ArgumentException) - { - return null; - } - } - - /// - /// The unpacked bytes of , counted against the part, package and ratio limits. A part whose - /// header already claims too much is refused before it is unpacked. - /// - public Stream Open(ZipArchiveEntry entry) - { - var name = entry.FullName; - if (entry.Length > Limits.MaxPartBytes) - throw PartTooLarge(name); - if (_total + entry.Length > Limits.MaxPackageBytes) - throw PackageTooLarge(); - Stream inner; - try - { - inner = entry.Open(); - } - catch (Exception ex) when (ex is IOException or NotSupportedException or ArgumentException) - { - throw new InvalidDataException($"The part {name} of the package can't be unpacked ({ex.Message}).", ex); - } - var compressed = Math.Max(1, entry.CompressedLength); - long counted = 0; - return new LimitedStream(new DamageStream(inner, name), Limits.MaxPartBytes, () => PartTooLarge(name), read => - { - _total += read - counted; - counted = read; - if (_total > Limits.MaxPackageBytes) - throw PackageTooLarge(); - if (read > Limits.InflateRatioFloor && read / compressed > Limits.MaxInflateRatio) - throw DocumentLimits.Exceeded($"The part {name} unpacks to more than {Limits.MaxInflateRatio} times its packed size."); - }); - } - - /// - /// An XML part read by ; its elements count against the package's. Throws - /// for a part that is not well-formed XML (callers say which part of which format). - /// - public XDocument LoadXml(ZipArchiveEntry entry, bool preserveWhitespace = true) - { - using var stream = Open(entry); - try - { - var document = SafeXml.Load(stream, Limits.Xml(_elements, preserveWhitespace), - message => DocumentLimits.Exceeded($"{entry.FullName}: {message}"), out var elements); - _elements += elements; - return document; - } - catch (Exception ex) when (ex is DecoderFallbackException or ArgumentException) - { - throw new XmlException(ex.Message, ex); - } - } - - /// The bytes of a part; null when it holds more than (the part is then left out). - public byte[]? ReadBytes(ZipArchiveEntry entry, long max) - { - if (entry.Length > max) - return null; - using var stream = Open(entry); - using var copy = new MemoryStream((int)Math.Min(entry.Length, 1024 * 1024)); - var buffer = new byte[81920]; - int count; - while ((count = stream.Read(buffer)) > 0) - { - if (copy.Length + count > max) - return null; - copy.Write(buffer, 0, count); - } - return copy.ToArray(); - } - - /// - /// Unpacks a picture into a new file at . Returns false (and leaves no file) when its - /// header claims more than . - /// - public bool ExtractPicture(ZipArchiveEntry entry, string path) - { - using (var source = Open(entry)) - using (var target = File.Create(path)) - source.CopyTo(target); - if (PictureSize.Read(path) is { } size && (long)size.Width * size.Height > Limits.MaxPicturePixels) - { - File.Delete(path); - return false; - } - return true; - } - - /// - /// The extension of a part name for a file Filee writes: "." and up to 8 ASCII letters or digits, else "" (a name - /// in a hostile package may hold anything, ":stream" included). - /// - public static string SafeExtension(string name) - { - var dot = name.LastIndexOf('.'); - if (dot < 0 || name.Length - dot - 1 is < 1 or > 8 || name.AsSpan(dot + 1).ContainsAnyExcept(AsciiLettersAndDigits)) - return ""; - return name[dot..].ToLowerInvariant(); - } - - private static readonly System.Buffers.SearchValues AsciiLettersAndDigits = - System.Buffers.SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); - - /// Reads a part; a package cut short or damaged inside a part is invalid data, not a failing disk. - private sealed class DamageStream(Stream inner, string name) : Stream - { - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); - - public override int Read(Span buffer) - { - try - { - return inner.Read(buffer); - } - catch (IOException ex) - { - throw new InvalidDataException($"The part {name} of the package is damaged ({ex.Message}).", ex); - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) - inner.Dispose(); - base.Dispose(disposing); - } - - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } - - private Exception PartTooLarge(string name) => - DocumentLimits.Exceeded($"The part {name} unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPartBytes)}."); - - private Exception PackageTooLarge() => - DocumentLimits.Exceeded($"The package unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPackageBytes)}."); -} diff --git a/src/Filee.Engines/Documents/LayoutGuard.cs b/src/Filee.Engines/Documents/LayoutGuard.cs deleted file mode 100644 index 79c142fac..000000000 --- a/src/Filee.Engines/Documents/LayoutGuard.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Guards for the layout loop, so a document can't keep the layout engine busy forever: every paragraph checks the -// cancellation token and the time budget, every page counts against MaxPages, and pages in a row that receive nothing -// mean the layout is stuck. An object larger than every page is clipped to the area it goes into and placed, so it -// can't push itself from page to page without end. - -using System.Diagnostics; - -namespace Filee.Engines.Documents; - -/// Checks a layout run against . -internal sealed class LayoutGuard -{ - private readonly DocumentLimits _limits; - private readonly CancellationToken _cancellationToken; - private readonly Func _elapsed; - private int _pages; - private int _emptyInARow; - - /// Time since the layout started (tests pass their own clock). - public LayoutGuard(DocumentLimits limits, CancellationToken cancellationToken, Func? elapsed = null) - { - _limits = limits; - _cancellationToken = cancellationToken; - if (elapsed is null) - { - var start = Stopwatch.GetTimestamp(); - elapsed = () => Stopwatch.GetElapsedTime(start); - } - _elapsed = elapsed; - } - - /// Pages finished so far. - public int Pages => _pages; - - /// Called before each paragraph is laid out: throws when the job was cancelled or the time is up. - /// The job was cancelled. - public void Paragraph() - { - _cancellationToken.ThrowIfCancellationRequested(); - if (_elapsed() > _limits.LayoutTimeBudget) - throw DocumentLimits.Exceeded($"Laying out the document took longer than {Describe(_limits.LayoutTimeBudget)}."); - } - - private static string Describe(TimeSpan time) => time.TotalMinutes >= 1 - ? time.TotalMinutes == 1 ? "1 minute" : $"{time.TotalMinutes:0} minutes" - : time.TotalSeconds == 1 ? "1 second" : $"{time.TotalSeconds:0} seconds"; - - /// Called when a page is finished; when nothing was placed on it. - public void PageFinished(bool empty) - { - if (++_pages > _limits.MaxPages) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(_limits.MaxPages)} pages."); - _emptyInARow = empty ? _emptyInARow + 1 : 0; - if (_emptyInARow > _limits.MaxConsecutiveEmptyPages) - throw DocumentLimits.Exceeded($"The layout made more than {_limits.MaxConsecutiveEmptyPages} empty pages in a row."); - } - - /// - /// The size an object is placed with in an area of × - /// (the body of an empty page): its own size when it fits, else clipped to the area. Clipped tells the - /// layout to draw only that part rather than move the object to the next page again. Sizes that are not finite - /// numbers or negative count as 0. - /// - public static (double Width, double Height, bool Clipped) Fit(double width, double height, double areaWidth, double areaHeight) - { - static double Clean(double value) => double.IsFinite(value) && value > 0 ? value : 0; - (width, height, areaWidth, areaHeight) = (Clean(width), Clean(height), Clean(areaWidth), Clean(areaHeight)); - var clipped = width > areaWidth || height > areaHeight; - return (Math.Min(width, areaWidth), Math.Min(height, areaHeight), clipped); - } -} diff --git a/src/Filee.Engines/Documents/PictureSize.cs b/src/Filee.Engines/Documents/PictureSize.cs deleted file mode 100644 index ce944b0a1..000000000 --- a/src/Filee.Engines/Documents/PictureSize.cs +++ /dev/null @@ -1,146 +0,0 @@ -// The pixel size a picture's header claims, read without decoding it, so a picture that would take gigabytes once -// decoded (a few kilobytes of PNG can claim 100,000 × 100,000 pixels) is left out before anything decodes it. PNG, -// JPEG, GIF, BMP and WebP; other formats (TIFF, EMF, WMF, SVG) give no size here and are checked where they are drawn. - -using System.Buffers.Binary; - -namespace Filee.Engines.Documents; - -/// Reads the width and height from a picture's header. -internal static class PictureSize -{ - /// JPEG segments looked at before the frame header (files have a dozen). - private const int MaxJpegSegments = 1000; - - /// The size in pixels; null for formats it does not know and headers it can't read. - public static (int Width, int Height)? Read(string path) - { - try - { - using var stream = File.OpenRead(path); - return Read(stream); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return null; - } - } - - /// - public static (int Width, int Height)? Read(Stream stream) - { - Span head = stackalloc byte[30]; - var length = ReadUpTo(stream, head); - head = head[..length]; - if (head is [0x89, (byte)'P', (byte)'N', (byte)'G', ..] && head.Length >= 24) - return Size(BinaryPrimitives.ReadUInt32BigEndian(head[16..]), BinaryPrimitives.ReadUInt32BigEndian(head[20..])); - if (head is [(byte)'G', (byte)'I', (byte)'F', (byte)'8', ..] && head.Length >= 10) - return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[6..]), BinaryPrimitives.ReadUInt16LittleEndian(head[8..])); - if (head is [(byte)'B', (byte)'M', ..] && head.Length >= 26) - { - var header = BinaryPrimitives.ReadUInt32LittleEndian(head[14..]); - return header == 12 - ? Size(BinaryPrimitives.ReadUInt16LittleEndian(head[18..]), BinaryPrimitives.ReadUInt16LittleEndian(head[20..])) - : Size((uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[18..])), (uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[22..]))); - } - if (head is [(byte)'R', (byte)'I', (byte)'F', (byte)'F', _, _, _, _, (byte)'W', (byte)'E', (byte)'B', (byte)'P', ..] && head.Length >= 30) - return WebP(head); - if (head is [0xFF, 0xD8, ..]) - return Jpeg(stream, head); - return null; - } - - private static (int Width, int Height)? WebP(ReadOnlySpan head) - { - var chunk = head.Slice(12, 4); - if (chunk.SequenceEqual("VP8X"u8)) - { - var width = head[24] | head[25] << 8 | head[26] << 16; - var height = head[27] | head[28] << 8 | head[29] << 16; - return Size((uint)width + 1, (uint)height + 1); - } - if (chunk.SequenceEqual("VP8L"u8) && head[20] == 0x2F) - { - var bits = BinaryPrimitives.ReadUInt32LittleEndian(head[21..]); - return Size((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1); - } - if (chunk.SequenceEqual("VP8 "u8)) - return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[26..]) & 0x3FFFu, BinaryPrimitives.ReadUInt16LittleEndian(head[28..]) & 0x3FFFu); - return null; - } - - /// The first frame header (SOFn) after the segments before it. - private static (int Width, int Height)? Jpeg(Stream stream, ReadOnlySpan head) - { - // Continue from byte 2 of the file: the head was read already. - var buffered = new Queue(head[2..].ToArray()); - int Next() - { - if (buffered.Count > 0) - return buffered.Dequeue(); - return stream.ReadByte(); - } - - Span frame = stackalloc byte[5]; - for (var segment = 0; segment < MaxJpegSegments; segment++) - { - var marker = Next(); - if (marker < 0) - return null; - if (marker != 0xFF) - return null; - var code = Next(); - while (code == 0xFF) - code = Next(); - if (code < 0 || code is 0xD9 or 0xDA) - return null; // end of image or scan data before any frame header - if (code is 0x01 or (>= 0xD0 and <= 0xD7)) - continue; // markers without a length - var high = Next(); - var low = Next(); - if (high < 0 || low < 0) - return null; - var length = high << 8 | low; - if (length < 2) - return null; - if (code is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) - { - for (var i = 0; i < frame.Length; i++) - { - var b = Next(); - if (b < 0) - return null; - frame[i] = (byte)b; - } - return Size(BinaryPrimitives.ReadUInt16BigEndian(frame[3..]), BinaryPrimitives.ReadUInt16BigEndian(frame[1..])); - } - var skip = length - 2; - for (; skip > 0 && buffered.Count > 0; skip--) - buffered.Dequeue(); - if (stream.CanSeek) - { - if (stream.Seek(skip, SeekOrigin.Current) > stream.Length) - return null; - continue; - } - for (; skip > 0; skip--) - { - if (Next() < 0) - return null; - } - } - return null; - } - - private static (int Width, int Height)? Size(uint width, uint height) => - width == 0 || height == 0 ? null : ((int)Math.Min(width, int.MaxValue), (int)Math.Min(height, int.MaxValue)); - - private static int ReadUpTo(Stream stream, Span buffer) - { - var total = 0; - int read; - while (total < buffer.Length && (read = stream.Read(buffer[total..])) > 0) - total += read; - return total; - } -} diff --git a/src/Filee.Engines/Geo/KmlFormat.cs b/src/Filee.Engines/Geo/KmlFormat.cs index c22612bdc..c6c35c379 100644 --- a/src/Filee.Engines/Geo/KmlFormat.cs +++ b/src/Filee.Engines/Geo/KmlFormat.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Xml; using System.Xml.Linq; -using Filee.Engines.Infrastructure; namespace Filee.Engines.Geo; @@ -410,15 +409,67 @@ internal static class GeoXml /// /// Parses XML without DTDs or external entities, so a hostile file can't read files or expand into gigabytes, and - /// refuses elements nested more than levels (see ). Namespace - /// declarations and prefixes are not kept; names are the local name and namespace. + /// refuses elements nested more than levels. The tree is built here from the reader + /// rather than by XDocument.Load, which takes time quadratic in the depth (a 1.4 MB file nested 100,000 deep + /// kept it busy for 48 seconds, the reader alone needs a quarter of a second) and so couldn't be allowed to see such + /// a file. Namespace declarations and prefixes are not kept; names are the local name and namespace. /// public static XDocument Load(Stream stream, int maxElements = MaxElements) { - try + var settings = new XmlReaderSettings { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersFromEntities = 0, + CheckCharacters = false, // About 1 GB of memory at the most: the tree takes some 16 bytes for every character. - return SafeXml.Load(stream, new XmlLimits(MaxElementDepth, maxElements, 1L << 26, KeepNamespaceDeclarations: false, CheckCharacters: false)); + MaxCharactersInDocument = 1L << 26, + }; + try + { + using var reader = XmlReader.Create(stream, settings); + var document = new XDocument(); + XContainer current = document; + var depth = 0; + var elements = 0; + while (reader.Read()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + if (++depth > MaxElementDepth) + throw new InvalidDataException($"The file nests elements more than {MaxElementDepth} levels deep."); + if (++elements > maxElements) + throw new InvalidDataException("The file has more than " + maxElements.ToString("N0", System.Globalization.CultureInfo.InvariantCulture) + " elements."); + var element = new XElement(XName.Get(reader.LocalName, reader.NamespaceURI)); + var empty = reader.IsEmptyElement; + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + if (reader.NamespaceURI != "http://www.w3.org/2000/xmlns/") + element.SetAttributeValue(XName.Get(reader.LocalName, reader.NamespaceURI), reader.Value); + } + reader.MoveToElement(); + } + current.Add(element); + if (empty) + depth--; + else + current = element; + break; + case XmlNodeType.EndElement: + current = current.Parent ?? (XContainer)document; + depth--; + break; + case XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace or XmlNodeType.Whitespace: + // Text outside the root element (white space between declarations) is no content. + if (current is XElement) + current.Add(new XText(reader.Value)); + break; + } + } + return document; } catch (XmlException ex) { diff --git a/src/Filee.Engines/Infrastructure/LimitedStream.cs b/src/Filee.Engines/Geo/LimitedStream.cs similarity index 51% rename from src/Filee.Engines/Infrastructure/LimitedStream.cs rename to src/Filee.Engines/Geo/LimitedStream.cs index ed171a016..57e7aa38a 100644 --- a/src/Filee.Engines/Infrastructure/LimitedStream.cs +++ b/src/Filee.Engines/Geo/LimitedStream.cs @@ -1,31 +1,13 @@ // A read-only wrapper that counts the bytes read through it and fails once they pass a limit, so a ZIP entry that // unpacks to far more than its header says (a ZIP bomb) stops at a known size instead of filling the memory. -namespace Filee.Engines.Infrastructure; +namespace Filee.Engines.Geo; -/// Reads at most limit bytes of inner, then throws ( by default). -internal sealed class LimitedStream : Stream +/// Reads at most limit bytes of , then throws . +internal sealed class LimitedStream(Stream inner, long limit, string message) : Stream { - private readonly Stream _inner; - private readonly long _limit; - private readonly Func _over; - private readonly Action? _onRead; private long _read; - public LimitedStream(Stream inner, long limit, string message) : this(inner, limit, () => new InvalidDataException(message)) - { - } - - /// The exception thrown once more than bytes were read. - /// Called after every read with the number of bytes read so far (for limits shared by several streams). - public LimitedStream(Stream inner, long limit, Func over, Action? onRead = null) - { - _inner = inner; - _limit = limit; - _over = over; - _onRead = onRead; - } - public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; @@ -41,19 +23,17 @@ public override long Position public override int Read(Span buffer) { - var read = _inner.Read(buffer); + var read = inner.Read(buffer); _read += read; - if (_read > _limit) - throw _over(); - if (read > 0) - _onRead?.Invoke(_read); + if (_read > limit) + throw new InvalidDataException(message); return read; } protected override void Dispose(bool disposing) { if (disposing) - _inner.Dispose(); + inner.Dispose(); base.Dispose(disposing); } diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs index 0d8a43885..c70003a59 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Xml.Linq; -using Filee.Engines.Documents; namespace Filee.Engines.Hwp.Hwpx.Docx; @@ -59,7 +58,6 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w { foreach (var obj in objects) { - _budget.Object(); if (obj.Name == Pic + "pic") { if (Picture(obj, width, height, anchor) is { } picture) @@ -81,7 +79,6 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w private IEnumerable Group(XElement group, int width, int height, HAnchor? anchor, (double X, double Y, double ScaleX, double ScaleY) parent) { - using var level = _budget.Enter(); var xfrm = group.Element(Wpg + "grpSpPr")?.Element(A + "xfrm") ?? group.Element(A + "grpSpPr")?.Element(A + "xfrm"); var chOff = xfrm?.Element(A + "chOff"); var chExt = xfrm?.Element(A + "chExt"); @@ -233,7 +230,6 @@ private HTextBox TextBox(XElement wsp, XElement content, int width, int height, /// Text box paragraphs; Word's document grid does not apply inside text boxes. private void ReadTextBoxContent(XElement content, List output) { - using var level = _budget.Enter(); _textBoxDepth++; try { @@ -404,12 +400,13 @@ private static int OffsetOf(XElement? position) => return null; if (_media.TryGetValue(rel.Target, out var existing)) return existing; - if (_package.GetEntry(rel.Target) is not { } entry) + if (_zip.GetEntry(rel.Target) is not { } entry) return null; Directory.CreateDirectory(_mediaFolder); - string? path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{DocumentPackage.SafeExtension(rel.Target)}"); - if (!_package.ExtractPicture(entry, path)) - path = null; // larger than any picture should be: left out + var path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{Path.GetExtension(rel.Target)}"); + using (var source = entry.Open()) + using (var target = File.Create(path)) + source.CopyTo(target); _media[rel.Target] = path; return path; } @@ -425,12 +422,10 @@ private IEnumerable Vml(XElement container, HCharFormat format) continue; if (shape.Name.LocalName == "group") { - using var level = _budget.Enter(); foreach (var inline in Vml(shape, format)) yield return inline; continue; } - _budget.Object(); if (shape.Name.LocalName is "shapetype") continue; diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs index 537fcc603..328980fe2 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs @@ -294,10 +294,8 @@ private void LoadNumbering(Part? part) var root = part?.Xml.Root; if (root is null) return; - // Of two definitions with one id (a damaged file) the first counts. - var abstracts = new Dictionary(); - foreach (var abstractNum in root.Elements(W + "abstractNum")) - abstracts.TryAdd(Attr(abstractNum, "abstractNumId") ?? "", abstractNum); + var abstracts = root.Elements(W + "abstractNum") + .ToDictionary(a => Attr(a, "abstractNumId") ?? "", a => a); foreach (var num in root.Elements(W + "num")) { var numId = Attr(num, "numId") ?? ""; @@ -337,11 +335,7 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // ───────────────────────── Resolution ───────────────────────── /// Merged properties of a paragraph or character style including everything it is based on. - private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) => ResolveStyle(styleId, 0); - - /// Styles below this one in the chain: a chain longer than (a - /// damaged or hostile file; real chains are a few long) starts from the defaults instead of overflowing the stack. - private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId, int depth) + private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) { if (styleId is null || !_styles.TryGetValue(styleId, out var style)) return (_defaultPara.Clone(), _defaultRun.Clone()); @@ -350,9 +344,7 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // Guard against basedOn cycles in damaged files. _resolvedStyles[styleId] = (_defaultPara.Clone(), _defaultRun.Clone()); - var (para, run) = style.BasedOn is { } parent && parent != styleId && depth < MaxStyleChain - ? ResolveStyle(parent, depth + 1) - : (_defaultPara.Clone(), _defaultRun.Clone()); + var (para, run) = style.BasedOn is { } parent && parent != styleId ? ResolveStyle(parent) : (_defaultPara.Clone(), _defaultRun.Clone()); para.Apply(style.PPr); run.Apply(style.RPr); _resolvedStyles[styleId] = (para.Clone(), run.Clone()); @@ -395,7 +387,7 @@ private RunProps RunProperties(XElement? rPr, RunProps paragraphRun) { // Character styles only carry run properties; apply their chain on top of the paragraph's. var chain = new List