diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml
new file mode 100644
index 000000000..caa5c0ca3
--- /dev/null
+++ b/.github/workflows/fuzz.yml
@@ -0,0 +1,58 @@
+# 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 e2b6f68e1..bf918f9f8 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -628,7 +628,8 @@ 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. XML is read without DTDs; a part may unpack to 64 MB, a picture to
+ 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
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.
@@ -1234,6 +1235,31 @@ 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 5c54c4b6c..b4d9688ab 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -129,6 +129,7 @@
"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 6a234a92d..b48177ad7 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -129,6 +129,7 @@
"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 a168cd0e1..746e90251 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -135,6 +135,7 @@
"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 dea96a597..83c051b77 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -129,6 +129,7 @@
"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 948a24c14..f29a60ee0 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -135,6 +135,7 @@
"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 f8f1ea2f7..ee0368fe7 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -129,6 +129,7 @@
"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 2c897a5de..bd821d60d 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -129,6 +129,7 @@
"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 bdf82d150..3013b8e04 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -135,6 +135,7 @@
"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 b765447f8..82fcb7340 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -129,6 +129,7 @@
"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 0bfb19c6d..23b597c83 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -135,6 +135,7 @@
"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 c98e43d63..16eb4a1dd 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -135,6 +135,7 @@
"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 8692faccf..5227026e5 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -135,6 +135,7 @@
"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 7683e5f09..0d1e76402 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -135,6 +135,7 @@
"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 0c5973197..52aaa3554 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -129,6 +129,7 @@
"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 e350fbc62..efac198d4 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -129,6 +129,7 @@
"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 4b96b320d..4ffdde510 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -135,6 +135,7 @@
"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 8a56d201c..b83c0221d 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -135,6 +135,7 @@
"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
new file mode 100644
index 000000000..631f19cb8
--- /dev/null
+++ b/src/Filee.Engines/Documents/DocumentBudget.cs
@@ -0,0 +1,58 @@
+// 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
new file mode 100644
index 000000000..892cf8e07
--- /dev/null
+++ b/src/Filee.Engines/Documents/DocumentLimits.cs
@@ -0,0 +1,96 @@
+// 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
new file mode 100644
index 000000000..0cccc7131
--- /dev/null
+++ b/src/Filee.Engines/Documents/DocumentPackage.cs
@@ -0,0 +1,240 @@
+// 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
new file mode 100644
index 000000000..79c142fac
--- /dev/null
+++ b/src/Filee.Engines/Documents/LayoutGuard.cs
@@ -0,0 +1,71 @@
+// 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
new file mode 100644
index 000000000..ce944b0a1
--- /dev/null
+++ b/src/Filee.Engines/Documents/PictureSize.cs
@@ -0,0 +1,146 @@
+// 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 c6c35c379..c22612bdc 100644
--- a/src/Filee.Engines/Geo/KmlFormat.cs
+++ b/src/Filee.Engines/Geo/KmlFormat.cs
@@ -11,6 +11,7 @@
using System.Text.Json;
using System.Xml;
using System.Xml.Linq;
+using Filee.Engines.Infrastructure;
namespace Filee.Engines.Geo;
@@ -409,67 +410,15 @@ 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. 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.
+ /// refuses elements nested more than levels (see ). Namespace
+ /// declarations and prefixes are not kept; names are the local name and namespace.
///
public static XDocument Load(Stream stream, int maxElements = MaxElements)
{
- 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.
- 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;
+ // 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));
}
catch (XmlException ex)
{
diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs
index c70003a59..0d8a43885 100644
--- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs
+++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs
@@ -3,6 +3,7 @@
using System.Globalization;
using System.Xml.Linq;
+using Filee.Engines.Documents;
namespace Filee.Engines.Hwp.Hwpx.Docx;
@@ -58,6 +59,7 @@ 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)
@@ -79,6 +81,7 @@ 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");
@@ -230,6 +233,7 @@ 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
{
@@ -400,13 +404,12 @@ private static int OffsetOf(XElement? position) =>
return null;
if (_media.TryGetValue(rel.Target, out var existing))
return existing;
- if (_zip.GetEntry(rel.Target) is not { } entry)
+ if (_package.GetEntry(rel.Target) is not { } entry)
return null;
Directory.CreateDirectory(_mediaFolder);
- 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);
+ 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
_media[rel.Target] = path;
return path;
}
@@ -422,10 +425,12 @@ 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 328980fe2..537fcc603 100644
--- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs
+++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs
@@ -294,8 +294,10 @@ private void LoadNumbering(Part? part)
var root = part?.Xml.Root;
if (root is null)
return;
- var abstracts = root.Elements(W + "abstractNum")
- .ToDictionary(a => Attr(a, "abstractNumId") ?? "", a => a);
+ // 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);
foreach (var num in root.Elements(W + "num"))
{
var numId = Attr(num, "numId") ?? "";
@@ -335,7 +337,11 @@ 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)
+ 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)
{
if (styleId is null || !_styles.TryGetValue(styleId, out var style))
return (_defaultPara.Clone(), _defaultRun.Clone());
@@ -344,7 +350,9 @@ 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 ? ResolveStyle(parent) : (_defaultPara.Clone(), _defaultRun.Clone());
+ var (para, run) = style.BasedOn is { } parent && parent != styleId && depth < MaxStyleChain
+ ? ResolveStyle(parent, depth + 1)
+ : (_defaultPara.Clone(), _defaultRun.Clone());
para.Apply(style.PPr);
run.Apply(style.RPr);
_resolvedStyles[styleId] = (para.Clone(), run.Clone());
@@ -387,7 +395,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