diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..33f3283 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,74 @@ +name: Fuzz + +# Nightly cargo-fuzz run of every target in crates/markkit/fuzz (too slow +# for PRs). Each target checks the S17 output oracle, not just "no panic". +# Crashes upload as artifacts; reproduce with +# cargo +nightly fuzz run (from crates/markkit) +# See knowledge/security/fuzzing.md. + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + inputs: + duration: + description: "Seconds per target (positive integer)" + required: false + default: "300" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + fuzz: + name: Fuzz ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + target: [html, document, docx_xml, xlsx_xml, xlsb_bin, ods_xml, xls_biff, pdf, markdown, csv, render_ir] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/markkit/fuzz + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Cache corpus + uses: actions/cache@v4 + with: + path: crates/markkit/fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.run_id }} + restore-keys: fuzz-corpus-${{ matrix.target }}- + - name: Run ${{ matrix.target }} + working-directory: crates/markkit + env: + TARGET: ${{ matrix.target }} + DURATION_INPUT: ${{ github.event.inputs.duration || '300' }} + run: | + # workflow_dispatch input: validate before it reaches the shell. + if ! [[ "$DURATION_INPUT" =~ ^[1-9][0-9]{0,4}$ ]]; then + echo "::error::duration must be a positive integer (max 5 digits), got: $DURATION_INPUT" + exit 1 + fi + # New inputs go to the first (cached) dir; the rest are read-only seeds. + mkdir -p "fuzz/corpus/$TARGET" + seeds=() + [ -d "fuzz/seeds/$TARGET" ] && seeds+=("fuzz/seeds/$TARGET") + [ "$TARGET" = html ] && seeds+=(tests/fixtures/html) + cargo +nightly fuzz run -O "$TARGET" "fuzz/corpus/$TARGET" "${seeds[@]}" -- \ + -max_total_time="$DURATION_INPUT" -max_len=65536 -print_final_stats=1 + - name: Upload crashes + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fuzz-crashes-${{ matrix.target }} + path: crates/markkit/fuzz/artifacts/${{ matrix.target }} + retention-days: 30 diff --git a/AGENTS.md b/AGENTS.md index 032b119..3b96a7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | formats/format-support | Formats, bytes-in API, detection order, DOCX + spreadsheet mapping | | integrations/tool-contract | `MarkkitTool` LLM tool (`tool` feature), Everruns toolkit contract | | security/security-model | One-page threat/invariant summary | +| security/fuzzing | cargo-fuzz targets, S17 oracle, nightly workflow, crash triage | | security/threat-model | Threats T1-T9, mitigations, test map, resource limits, invariants S1-S21 | | runtimes/python-package | PyO3 `markkit` module, maturin, PyPI | | runtimes/wasm-package | wasm-bindgen `@everruns/markkit-wasm`, browser/edge, build + tests | diff --git a/CHANGELOG.md b/CHANGELOG.md index c51a0da..783e3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). pulldown-cmark and rendered back, so raw HTML never passes through. Front matter `title:` or the first `# ` heading becomes `Extraction::title`. Available in the CLI, Python, Node and WASM packages; `format` serializes as `"text"` and `"markdown"`. +- Nightly cargo-fuzz targets for every parser and the renderer (`crates/markkit/fuzz`, `just fuzz`, + `.github/workflows/fuzz.yml`), each checking the output-safety oracle. - HTML page metadata: `extract_document` fills `Extraction::title` (Open Graph, JSON-LD or ``) and the new `Extraction::metadata` (description, author, published date, language, canonical URL, site name, lead image) from `<meta>`, Open Graph, `<link rel=canonical>` and @@ -115,6 +117,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - The JSON IR of DOCX and spreadsheet tables now holds raw text (pipes were pre-escaped before). - Math TeX (taken from page markup) renders as code, GitHub's `` $`tex`$ `` and ` ```math ` forms, instead of `$tex$` / `$$`, so it can no longer emit raw HTML or links in plain GFM. +- XLS files with truncated or inconsistent BIFF records (found by fuzzing) are rejected with an + error instead of aborting the process inside calamine. Formula token streams are ignored + (cached values were already the only thing rendered). - Hidden content is skipped by default as a prompt-injection defence: HTML elements with `hidden`, `aria-hidden="true"` or inline `display:none` / `visibility:hidden` / `opacity:0` / `font-size:0`, `<template>`, and DOCX hidden (`w:vanish`) runs (warning `hidden_content_skipped`). Opt back in diff --git a/crates/markkit/fuzz/.gitignore b/crates/markkit/fuzz/.gitignore new file mode 100644 index 0000000..fe68c97 --- /dev/null +++ b/crates/markkit/fuzz/.gitignore @@ -0,0 +1,4 @@ +target/ +corpus/ +artifacts/ +coverage/ diff --git a/crates/markkit/fuzz/Cargo.lock b/crates/markkit/fuzz/Cargo.lock new file mode 100644 index 0000000..f3caaa8 --- /dev/null +++ b/crates/markkit/fuzz/Cargo.lock @@ -0,0 +1,1342 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calamine" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ca341b94bc4b29e08970b1a03914fad2dfe177e2ecb67fcd6de9046004aafb" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml", + "serde", + "zip", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codepage" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdff162541cd8b79de82e2edcc7eff3a8c2a6dc3d75152636028f96d93de3b26" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cssparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd50d24b1b75aa817bb0339cb9f2e0e664bdfab1e160a69a0fda9145b77fab2" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hayro-ccitt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" + +[[package]] +name = "hayro-jbig2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" +dependencies = [ + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" + +[[package]] +name = "hayro-syntax" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" +dependencies = [ + "hayro-jbig2", + "hayro-jpeg2000", + "rustc-hash", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "html5ever" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" +dependencies = [ + "log", + "markup5ever", + "memchr", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markkit" +version = "0.1.1" +dependencies = [ + "calamine", + "cfb", + "ego-tree", + "hayro-syntax", + "html5ever", + "miniz_oxide", + "pulldown-cmark", + "quick-xml", + "scraper", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", + "zip", +] + +[[package]] +name = "markkit-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "cfb", + "libfuzzer-sys", + "markkit", + "miniz_oxide", + "pulldown-cmark", + "zip", +] + +[[package]] +name = "markup5ever" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", +] + +[[package]] +name = "selectors" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "servo_arc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "pin-project-lite", +] + +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "9.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6" +dependencies = [ + "crc32fast", + "flate2", + "getrandom", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/markkit/fuzz/Cargo.toml b/crates/markkit/fuzz/Cargo.toml new file mode 100644 index 0000000..fcbf241 --- /dev/null +++ b/crates/markkit/fuzz/Cargo.toml @@ -0,0 +1,110 @@ +# Fuzz targets for markkit (threat model T1, T2, T6, T7, T8, T9). +# Run: cargo +nightly fuzz run <target> -- -max_total_time=60 (from crates/markkit) +# Targets and corpus layout: knowledge/security/fuzzing.md + +[package] +name = "markkit-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } +pulldown-cmark = { version = "0.13", default-features = false } +# Fixture builders shared with tests/common (zip + OLE2 containers). +zip = { version = "9", default-features = false, features = ["deflate"] } +cfb = "0.15" +miniz_oxide = "0.9" + +[dependencies.markkit] +path = ".." +default-features = false +features = ["html", "docx", "spreadsheet", "pdf", "markdown"] + +# Keep out of the main workspace (nightly-only, separate lockfile). +[workspace] +members = ["."] + +[profile.release] +debug = 1 + +[[bin]] +name = "html" +path = "fuzz_targets/html.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "document" +path = "fuzz_targets/document.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "docx_xml" +path = "fuzz_targets/docx_xml.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "xlsx_xml" +path = "fuzz_targets/xlsx_xml.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "ods_xml" +path = "fuzz_targets/ods_xml.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "xls_biff" +path = "fuzz_targets/xls_biff.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "render_ir" +path = "fuzz_targets/render_ir.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "xlsb_bin" +path = "fuzz_targets/xlsb_bin.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "pdf" +path = "fuzz_targets/pdf.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "markdown" +path = "fuzz_targets/markdown.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "csv" +path = "fuzz_targets/csv.rs" +test = false +doc = false +bench = false diff --git a/crates/markkit/fuzz/fuzz_targets/csv.rs b/crates/markkit/fuzz/fuzz_targets/csv.rs new file mode 100644 index 0000000..fe1a8d9 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/csv.rs @@ -0,0 +1,19 @@ +//! Raw CSV / TSV input: dialect handling, row/column caps, table render. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + // First byte picks the dialect so both delimiters get coverage. + let name = if data.first().is_some_and(|b| b & 1 == 1) { + "fuzz.tsv" + } else { + "fuzz.csv" + }; + extract_and_check(data, DocumentHint::file_name(name)); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/document.rs b/crates/markkit/fuzz/fuzz_targets/document.rs new file mode 100644 index 0000000..84e7fb5 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/document.rs @@ -0,0 +1,13 @@ +//! Raw bytes through format detection and every parser (sniffed, no hint). +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + extract_and_check(data, DocumentHint::default()); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/docx_xml.rs b/crates/markkit/fuzz/fuzz_targets/docx_xml.rs new file mode 100644 index 0000000..bb8c595 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/docx_xml.rs @@ -0,0 +1,32 @@ +//! Structure-aware DOCX: fuzzed XML inside a valid package, so the input +//! reaches the WordprocessingML walker instead of failing at the ZIP header. +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::common::{DocxParts, docx_with}; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +#[derive(Arbitrary, Debug)] +struct Input<'a> { + body: &'a str, + rels: &'a str, + styles: Option<&'a str>, + numbering: Option<&'a str>, +} + +fuzz_target!(|input: Input<'_>| { + let size = input.body.len() + input.rels.len(); + if size > MAX_INPUT { + return; + } + let bytes = docx_with(DocxParts { + body: input.body, + rels: input.rels, + styles: input.styles, + numbering: input.numbering, + ..Default::default() + }); + extract_and_check(&bytes, DocumentHint::file_name("fuzz.docx")); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/html.rs b/crates/markkit/fuzz/fuzz_targets/html.rs new file mode 100644 index 0000000..33d4be9 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/html.rs @@ -0,0 +1,15 @@ +//! HTML → Markdown: no panic, output passes the S17 oracle. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit_fuzz::{MAX_INPUT, check_markdown, markkit}; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + let html = String::from_utf8_lossy(data); + if let Ok(md) = markkit().extract_html_sync(&html) { + check_markdown(&md); + } +}); diff --git a/crates/markkit/fuzz/fuzz_targets/markdown.rs b/crates/markkit/fuzz/fuzz_targets/markdown.rs new file mode 100644 index 0000000..11c6e90 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/markdown.rs @@ -0,0 +1,13 @@ +//! Raw Markdown input: re-parse through the IR and render back. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + extract_and_check(data, DocumentHint::file_name("fuzz.md")); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/ods_xml.rs b/crates/markkit/fuzz/fuzz_targets/ods_xml.rs new file mode 100644 index 0000000..d3424c8 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/ods_xml.rs @@ -0,0 +1,15 @@ +//! Structure-aware ODS: fuzzed `<office:spreadsheet>` content (row/column +//! repeats drive the grid pre-scan, T6b). +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::common::ods; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|inner: &str| { + if inner.len() > MAX_INPUT { + return; + } + extract_and_check(&ods(inner), DocumentHint::file_name("fuzz.ods")); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/pdf.rs b/crates/markkit/fuzz/fuzz_targets/pdf.rs new file mode 100644 index 0000000..e427903 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/pdf.rs @@ -0,0 +1,13 @@ +//! Raw PDF bytes: pre-scan, hayro object parsing, text-layer extraction. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|data: &[u8]| { + if data.len() > MAX_INPUT { + return; + } + extract_and_check(data, DocumentHint::file_name("fuzz.pdf")); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/render_ir.rs b/crates/markkit/fuzz/fuzz_targets/render_ir.rs new file mode 100644 index 0000000..a64d304 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/render_ir.rs @@ -0,0 +1,149 @@ +//! Arbitrary IR → Markdown renderer. Covers trees no parser emits today +//! (future formats, JSON IR from callers) against the S17 oracle. +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use markkit::MarkdownRenderer; +use markkit::ir::{Alignment, DefinitionItem, Document, ListItem, Node, TableCell}; +use markkit_fuzz::check_markdown; + +#[derive(Arbitrary, Debug)] +enum F { + Heading(u8, Vec<F>), + Paragraph(Vec<F>), + List(bool, Option<u64>, Vec<(Option<bool>, Vec<F>)>), + BlockQuote(Vec<F>), + CodeBlock(Option<String>, String), + Table(Vec<u8>, Vec<C>, Vec<Vec<C>>), + ThematicBreak, + DefinitionList(Vec<(Vec<F>, Vec<Vec<F>>)>), + Figure(Vec<F>, Option<Vec<F>>), + FootnoteDefinition(String, Vec<F>), + Text(String), + Bold(Vec<F>), + Italic(Vec<F>), + Strikethrough(Vec<F>), + InlineCode(String), + Link(String, Option<String>, Vec<F>), + Image(String, String), + LineBreak, + FootnoteReference(String), +} + +#[derive(Arbitrary, Debug)] +struct C(u8, u8, Vec<F>); + +const MAX_DEPTH: usize = 8; + +fn conv(nodes: Vec<F>, depth: usize) -> Vec<Node> { + if depth > MAX_DEPTH { + return Vec::new(); + } + nodes.into_iter().map(|n| node(n, depth + 1)).collect() +} + +fn cell(c: C, d: usize) -> TableCell { + TableCell::new(conv(c.2, d)).with_span(u32::from(c.0 % 4) + 1, u32::from(c.1 % 4) + 1) +} + +fn align(a: u8) -> Alignment { + match a % 4 { + 0 => Alignment::None, + 1 => Alignment::Left, + 2 => Alignment::Center, + _ => Alignment::Right, + } +} + +fn node(f: F, d: usize) -> Node { + match f { + F::Heading(level, c) => Node::Heading { + level: level % 6 + 1, + children: conv(c, d), + source: None, + }, + F::Paragraph(c) => Node::Paragraph { + children: conv(c, d), + source: None, + }, + F::List(ordered, start, items) => Node::List { + ordered, + start: if ordered { + start.map(|s| s % 1_000_000) + } else { + None + }, + items: items + .into_iter() + .map(|(checked, i)| match checked { + Some(c) => ListItem::task(conv(i, d), c), + None => ListItem::new(conv(i, d)), + }) + .collect(), + source: None, + }, + F::BlockQuote(c) => Node::BlockQuote { + children: conv(c, d), + source: None, + }, + F::CodeBlock(language, code) => Node::CodeBlock { + language, + code, + source: None, + }, + F::Table(a, h, r) => Node::Table { + alignments: a.into_iter().map(align).collect(), + headers: h.into_iter().map(|c| cell(c, d)).collect(), + rows: r + .into_iter() + .map(|row| row.into_iter().map(|c| cell(c, d)).collect()) + .collect(), + source: None, + }, + F::ThematicBreak => Node::ThematicBreak, + F::DefinitionList(items) => Node::DefinitionList { + items: items + .into_iter() + .map(|(t, defs)| { + DefinitionItem::new(conv(t, d), defs.into_iter().map(|x| conv(x, d)).collect()) + }) + .collect(), + source: None, + }, + F::Figure(c, caption) => Node::Figure { + children: conv(c, d), + caption: caption.map(|x| conv(x, d)), + source: None, + }, + F::FootnoteDefinition(label, c) => Node::FootnoteDefinition { + label, + children: conv(c, d), + source: None, + }, + F::Text(t) => Node::Text(t), + F::Bold(c) => Node::Bold { + children: conv(c, d), + }, + F::Italic(c) => Node::Italic { + children: conv(c, d), + }, + F::Strikethrough(c) => Node::Strikethrough { + children: conv(c, d), + }, + F::InlineCode(c) => Node::InlineCode(c), + F::Link(url, title, c) => Node::Link { + url, + title, + children: conv(c, d), + }, + F::Image(src, alt) => Node::Image { src, alt }, + F::LineBreak => Node::LineBreak, + F::FootnoteReference(label) => Node::FootnoteReference { label }, + } +} + +fuzz_target!(|nodes: Vec<F>| { + let doc = Document::new(conv(nodes, 0)); + check_markdown(&MarkdownRenderer::new().render(&doc)); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/xls_biff.rs b/crates/markkit/fuzz/fuzz_targets/xls_biff.rs new file mode 100644 index 0000000..b6415d6 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/xls_biff.rs @@ -0,0 +1,18 @@ +//! Structure-aware XLS: fuzzed BIFF8 workbook stream inside a valid OLE2 +//! container (DIMENSIONS / MULRK / BOUNDSHEET pre-scan, T6b-c). +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::common::xls_from_stream; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|stream: &[u8]| { + if stream.len() > MAX_INPUT { + return; + } + extract_and_check( + &xls_from_stream(stream), + DocumentHint::file_name("fuzz.xls"), + ); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/xlsb_bin.rs b/crates/markkit/fuzz/fuzz_targets/xlsb_bin.rs new file mode 100644 index 0000000..b3fc442 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/xlsb_bin.rs @@ -0,0 +1,17 @@ +//! Structure-aware XLSB: fuzzed BIFF12 worksheet stream inside a valid workbook. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::common::{Sheet, rezip, xlsb}; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|sheet_bin: &[u8]| { + if sheet_bin.len() > MAX_INPUT { + return; + } + let base = xlsb(&[Sheet::new("S", vec![])]); + let path = "xl/worksheets/sheet1.bin"; + let bytes = rezip(&base, &[path], &[(path, sheet_bin)]); + extract_and_check(&bytes, DocumentHint::file_name("fuzz.xlsb")); +}); diff --git a/crates/markkit/fuzz/fuzz_targets/xlsx_xml.rs b/crates/markkit/fuzz/fuzz_targets/xlsx_xml.rs new file mode 100644 index 0000000..7589a97 --- /dev/null +++ b/crates/markkit/fuzz/fuzz_targets/xlsx_xml.rs @@ -0,0 +1,19 @@ +//! Structure-aware XLSX: fuzzed worksheet XML inside a valid workbook. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use markkit::DocumentHint; +use markkit_fuzz::common::{Sheet, xlsx_with}; +use markkit_fuzz::{MAX_INPUT, extract_and_check}; + +fuzz_target!(|sheet_xml: &str| { + if sheet_xml.len() > MAX_INPUT { + return; + } + let bytes = xlsx_with( + &[Sheet::new("S", vec![])], + &[Some(sheet_xml.to_string())], + &[], + ); + extract_and_check(&bytes, DocumentHint::file_name("fuzz.xlsx")); +}); diff --git a/crates/markkit/fuzz/seeds/xls_biff/seed-basic b/crates/markkit/fuzz/seeds/xls_biff/seed-basic new file mode 100644 index 0000000..df89c56 Binary files /dev/null and b/crates/markkit/fuzz/seeds/xls_biff/seed-basic differ diff --git a/crates/markkit/fuzz/seeds/xls_biff/seed-chart b/crates/markkit/fuzz/seeds/xls_biff/seed-chart new file mode 100644 index 0000000..382404d Binary files /dev/null and b/crates/markkit/fuzz/seeds/xls_biff/seed-chart differ diff --git a/crates/markkit/fuzz/seeds/xls_biff/seed-mulrk b/crates/markkit/fuzz/seeds/xls_biff/seed-mulrk new file mode 100644 index 0000000..f4a4c0f Binary files /dev/null and b/crates/markkit/fuzz/seeds/xls_biff/seed-mulrk differ diff --git a/crates/markkit/fuzz/seeds/xls_biff/seed-names b/crates/markkit/fuzz/seeds/xls_biff/seed-names new file mode 100644 index 0000000..3610132 Binary files /dev/null and b/crates/markkit/fuzz/seeds/xls_biff/seed-names differ diff --git a/crates/markkit/fuzz/seeds/xls_biff/seed-sst b/crates/markkit/fuzz/seeds/xls_biff/seed-sst new file mode 100644 index 0000000..1ea43b2 Binary files /dev/null and b/crates/markkit/fuzz/seeds/xls_biff/seed-sst differ diff --git a/crates/markkit/fuzz/src/lib.rs b/crates/markkit/fuzz/src/lib.rs new file mode 100644 index 0000000..da40764 --- /dev/null +++ b/crates/markkit/fuzz/src/lib.rs @@ -0,0 +1,76 @@ +//! Shared helpers for markkit fuzz targets. +//! +//! Every target asserts the output-safety oracle (invariant S17), not just +//! "no panic": rendered Markdown parsed with pulldown-cmark (GFM) has no raw +//! HTML and no link/image with a non-allowlisted scheme. + +use markkit::{DocumentHint, Markkit}; +use pulldown_cmark::{Event, Options, Parser, Tag}; + +/// In-memory DOCX/XLSX/ODS/XLS builders shared with the integration tests. +#[path = "../../tests/common/mod.rs"] +pub mod common; + +/// Inputs above this size are skipped: limits are tested elsewhere and large +/// inputs only slow the fuzzer down. +pub const MAX_INPUT: usize = 64 * 1024; + +/// Extractor with default (safe) configuration. +pub fn markkit() -> Markkit { + Markkit::builder().build() +} + +/// Extract a document and check the oracle on success. Errors are fine. +pub fn extract_and_check(bytes: &[u8], hint: DocumentHint<'_>) { + if let Ok(e) = markkit().extract_document_sync(bytes, hint) { + check_markdown(&e.markdown); + } +} + +/// Panic when `md` would emit raw HTML or an unsafe destination when rendered. +pub fn check_markdown(md: &str) { + let opts = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + for ev in Parser::new_ext(md, opts) { + match ev { + Event::Html(h) | Event::InlineHtml(h) => panic!("raw HTML {h:?} in {md:?}"), + Event::Start(Tag::Link { dest_url, .. }) => check_scheme(&dest_url, false, md), + Event::Start(Tag::Image { dest_url, .. }) => check_scheme(&dest_url, true, md), + _ => {} + } + } +} + +fn check_scheme(url: &str, image: bool, md: &str) { + let probe: String = url + .trim_start_matches(|c: char| c <= ' ') + .chars() + // Browsers drop tabs, newlines and leading C0/space, not inner spaces. + .filter(|c| !matches!(c, '\t' | '\n' | '\r') && !c.is_ascii_control()) + .collect::<String>() + .to_ascii_lowercase(); + let Some(i) = probe.find([':', '/', '?', '#']) else { + return; + }; + if probe.as_bytes()[i] != b':' { + return; + } + let scheme = &probe[..i]; + // Not a scheme per the URL spec (e.g. `ht%20tps`): a relative URL. + let valid = scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); + if !valid { + return; + } + let ok = if image { + matches!(scheme, "http" | "https") || probe.starts_with("data:image/") + } else { + matches!(scheme, "http" | "https" | "mailto" | "tel" | "ftp") + }; + assert!(ok, "unsafe destination {url:?} in {md:?}"); + assert!( + !probe.starts_with("data:image/svg"), + "svg data image {url:?} in {md:?}" + ); +} diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs index f4645c7..2d0a436 100644 --- a/crates/markkit/src/spreadsheet.rs +++ b/crates/markkit/src/spreadsheet.rs @@ -19,9 +19,17 @@ //! - XLSB is read by our own bounds-checked BIFF12 reader ([`xlsb`]), streaming //! cells into the same capped grid; calamine's XLSB reader panics on //! malformed records. +//! - calamine's BIFF reader indexes records without bounds checks. `catch_unwind` +//! only helps where panics unwind (not WASM or `panic = "abort"` embedders), +//! so the XLS pre-scan also rejects every +//! record shape known to panic it (found by `fuzz/xls_biff`). Formula token +//! streams are not validated but neutralised (`cce = 0`): markkit renders +//! cached values only, and calamine's formula decompiler has too many +//! unchecked reads to mirror. calamine then reads a container rebuilt from the +//! exact stream we scanned, so its own CFB reader cannot see different bytes. use std::collections::BTreeMap; -use std::io::{Cursor, Read, Seek}; +use std::io::{Cursor, Read, Seek, Write}; use calamine::{Data, DataRef, Ods, Reader, SheetType, SheetVisible, Xls, Xlsx}; use quick_xml::events::{BytesStart, Event}; @@ -109,7 +117,7 @@ pub(crate) fn parse( } else if bytes.starts_with(OLE2_MAGIC) { let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)) .map_err(|e| err(format!("invalid XLS container: {e}")))?; - prescan_xls(&mut comp, config)?; + patched = Some(prescan_xls(&mut comp, config)?); (Container::Xls, xls_title(&mut comp)) } else { return Err(err("not a spreadsheet (expected XLSX, XLSB, XLS or ODS)")); @@ -696,20 +704,159 @@ fn read_u32(d: &[u8], at: usize) -> Option<u32> { /// Iterate BIFF records `(type, data)` until EOF record or end of stream. fn records(stream: &[u8]) -> impl Iterator<Item = (u16, &[u8])> { + records_at(stream).map(|(_, typ, data)| (typ, data)) +} + +/// [`records`] with the offset of each record's data. +fn records_at(stream: &[u8]) -> impl Iterator<Item = (usize, u16, &[u8])> { let mut pos = 0usize; std::iter::from_fn(move || { let typ = read_u16(stream, pos)?; let len = read_u16(stream, pos + 2)? as usize; let data = stream.get(pos + 4..pos + 4 + len)?; pos += 4 + len; - Some((typ, data)) + Some((pos - len, typ, data)) }) } +const CONTINUE: u16 = 0x003C; + +/// CONTINUE chunks that calamine appends to the record ending at `end`. +fn continues(stream: &[u8], mut end: usize) -> Vec<&[u8]> { + let mut out = Vec::new(); + while stream.len() > end + 4 && read_u16(stream, end) == Some(CONTINUE) { + let len = read_u16(stream, end + 2).unwrap_or(0) as usize; + let Some(d) = stream.get(end + 4..end + 4 + len) else { + break; + }; + out.push(d); + end += 4 + len; + } + out +} + +/// Mirror of calamine's SST walk (`parse_sst` / `read_rich_extended_string`): +/// `Err` where calamine would index out of bounds. Stops (`Ok`) wherever +/// calamine returns its own error. +fn check_sst(data: &[u8], cont: &[&[u8]]) -> Result<(), Error> { + let bad = || err("malformed XLS SST record"); + let Some(mut d) = data.get(8..) else { + return Ok(()); + }; + let mut cont = cont.iter().copied(); + loop { + if d.is_empty() { + match cont.next() { + Some(c) => d = c, + None => return Ok(()), + } + if d.is_empty() { + continue; + } + } + if d.len() < 3 { + return Ok(()); + } + let mut cch = read_u16(d, 0).unwrap_or(0) as usize; + let flags = d[2]; + d = &d[3..]; + let mut skip = 0usize; + if flags & 0x8 != 0 { + skip += 4 * read_u16(d, 0).ok_or_else(bad)? as usize; + d = &d[2..]; + } + if flags & 0x4 != 0 { + skip = skip.saturating_add(read_u32(d, 0).ok_or_else(bad)? as i32 as usize); + d = &d[4..]; + } + let mut wide = flags & 0x1 != 0; + while cch > 0 { + let l = cch.min(if wide { d.len() / 2 } else { d.len() }); + d = &d[if wide { 2 * l } else { l }..]; + cch -= l; + if cch > 0 { + match cont.next() { + Some(c) => d = c, + None => return Ok(()), + } + // calamine reads the fHighByte flag without a length check. + if d.is_empty() { + return Err(bad()); + } + wide = d[0] & 0x1 != 0; + d = &d[1..]; + } + } + while skip > 0 { + if d.is_empty() { + match cont.next() { + Some(c) => d = c, + None => return Ok(()), + } + } + let l = skip.min(d.len()); + d = &d[l..]; + skip -= l; + } + } +} + +/// Reject globals records calamine reads without length checks. +fn check_globals_record(typ: u16, data: &[u8], biff8: bool) -> Result<(), Error> { + let min = match typ { + // FILEPASS, CODEPAGE, DATEMODE, BOF + 0x002F | 0x0042 | 0x0022 | 0x0809 => 2, + // BOUNDSHEET: lbPlyPos, hsState, dt + 0x0085 => 6, + // LBL: fixed header up to the name + 0x0018 => 14, + // EXTERNSHEET (BIFF8 cXTI) + 0x0017 if biff8 => 2, + _ => 0, + }; + if data.len() < min { + return Err(err(format!("truncated XLS record 0x{typ:04X}"))); + } + if typ == 0x0018 { + // calamine decodes the first token of `rgce` (the last cce bytes). + let cce = read_u16(data, 4).unwrap_or(0) as usize; + let rgce = data + .len() + .checked_sub(cce) + .map(|at| &data[at..]) + .ok_or_else(|| err("malformed XLS LBL record"))?; + // PtgRef3d / PtgArea3d / PtgRefErr3d + PtgAreaErr3d, all token classes. + let need = match (rgce.first(), biff8) { + (Some(0x3A | 0x5A | 0x7A), true) => 7, + (Some(0x3B | 0x5B | 0x7B), true) => 11, + (Some(0x3C | 0x5C | 0x7C | 0x3D | 0x5D | 0x7D), true) => 3, + (Some(0x3A | 0x5A | 0x7A), false) => 18, + (Some(0x3B | 0x5B | 0x7B), false) => 21, + (Some(0x3C | 0x5C | 0x7C | 0x3D | 0x5D | 0x7D), false) => 13, + _ => 0, + }; + if rgce.len() < need { + return Err(err("malformed XLS LBL record")); + } + } + Ok(()) +} + +/// calamine's BIFF version detection from a BOF record (`parse_bof`). +fn bof_is_biff8(data: &[u8]) -> bool { + match read_u16(data, 0).unwrap_or(0) { + 0x0200 | 0x0002 | 0x0007 | 0x0300 | 0x0400 | 0x0500 => false, + 0 => read_u16(data, 2) != Some(0x1000), + _ => true, + } +} + +/// Scan the workbook stream and return a fresh OLE2 container holding only the +/// scanned (and formula-neutralised) stream, which is what calamine reads. fn prescan_xls<F: Read + Seek>( comp: &mut cfb::CompoundFile<F>, config: &MarkkitBuilder, -) -> Result<(), Error> { +) -> Result<Vec<u8>, Error> { let name = ["/Workbook", "/Book", "/WORKBOOK", "/BOOK"] .into_iter() .find(|n| comp.is_stream(n)) @@ -726,20 +873,44 @@ fn prescan_xls<F: Read + Seek>( "XLS Workbook stream exceeds {cap} bytes" ))); } - scan_biff(&stream, config.limits.max_spreadsheet_cells) + // Neutralising rewrites bytes, which can reframe records of an overlapping + // sheet; rescan until stable. Honest files settle on the second pass. + for _ in 0..3 { + let edits = scan_biff(&stream, config.limits.max_spreadsheet_cells)?; + if edits.is_empty() { + return wrap_workbook(&stream); + } + for at in edits { + stream[at..at + 2].fill(0); + } + } + Err(err("malformed XLS: overlapping sheet records")) +} + +fn wrap_workbook(stream: &[u8]) -> Result<Vec<u8>, Error> { + let werr = |e: std::io::Error| err(format!("cannot rebuild XLS container: {e}")); + let mut comp = cfb::CompoundFile::create(Cursor::new(Vec::new())).map_err(werr)?; + comp.create_stream("/Workbook") + .and_then(|mut s| s.write_all(stream)) + .map_err(werr)?; + comp.flush().map_err(werr)?; + Ok(comp.into_inner().into_inner()) } /// Estimate the cells calamine will allocate and reject inputs that would -/// exceed `max_cells`, or that trip calamine's unchecked arithmetic. -fn scan_biff(stream: &[u8], max_cells: u64) -> Result<(), Error> { +/// exceed `max_cells`, or that trip calamine's unchecked reads. Returns the +/// offsets of non-zero formula `cce` fields to neutralise. +fn scan_biff(stream: &[u8], max_cells: u64) -> Result<Vec<usize>, Error> { let mut offsets = Vec::new(); + let mut biff8 = true; + let mut end = 0usize; for (typ, data) in records(stream) { + end += 4 + data.len(); + check_globals_record(typ, data, biff8)?; match typ { - 0x0085 => { - if let Some(pos) = read_u32(data, 0) { - offsets.push(pos as usize); - } - } + 0x0809 => biff8 = bof_is_biff8(data), + 0x0085 => offsets.push(read_u32(data, 0).unwrap_or(0) as usize), + 0x00FC => check_sst(data, &continues(stream, end))?, 0x000A => break, _ => {} } @@ -747,20 +918,24 @@ fn scan_biff(stream: &[u8], max_cells: u64) -> Result<(), Error> { let limit = |n: u64| Error::ResourceLimit(format!("XLS grid of {n} cells exceeds limit {max_cells}")); let mut total: u64 = 0; + let mut edits = Vec::new(); for pos in offsets { let sheet = stream .get(pos..) .filter(|s| !s.is_empty()) .ok_or_else(|| err("XLS sheet offset out of range"))?; - total = total.saturating_add(scan_biff_sheet(sheet)?); + let cells = scan_biff_sheet(sheet, |at| edits.push(pos + at))?; + total = total.saturating_add(cells); if total > max_cells { return Err(limit(total)); } } - Ok(()) + edits.sort_unstable(); + edits.dedup(); + Ok(edits) } -fn scan_biff_sheet(sheet: &[u8]) -> Result<u64, Error> { +fn scan_biff_sheet(sheet: &[u8], mut neutralise: impl FnMut(usize)) -> Result<u64, Error> { let mut reserved: u64 = 0; let mut bounds: Option<(u32, u32, u32, u32)> = None; let mut see = |r: u32, c: u32| { @@ -769,8 +944,22 @@ fn scan_biff_sheet(sheet: &[u8]) -> Result<u64, Error> { Some((r0, r1, c0, c1)) => (r0.min(r), r1.max(r), c0.min(c), c1.max(c)), }); }; - for (typ, data) in records(sheet) { + // calamine skips nested substreams (embedded charts) and reads on past + // their EOF; the first BOF belongs to the sheet itself. + let mut seen_bof = false; + let mut depth = 0usize; + for (at, typ, data) in records_at(sheet) { + if depth > 0 { + match typ { + 0x0809 => depth += 1, + 0x000A => depth -= 1, + _ => {} + } + continue; + } match typ { + 0x0809 if seen_bof => depth += 1, + 0x0809 => seen_bof = true, 0x000A => break, // DIMENSIONS: calamine reserves rows × cols up front. 0x0200 => { @@ -781,7 +970,7 @@ fn scan_biff_sheet(sheet: &[u8]) -> Result<u64, Error> { read_u16(data, 4).unwrap_or(0) as u32, read_u16(data, 6).unwrap_or(0) as u32, ), - 14 => ( + 14 | 16 => ( read_u32(data, 0).unwrap_or(0), read_u32(data, 4).unwrap_or(0), read_u16(data, 8).unwrap_or(0) as u32, @@ -793,7 +982,9 @@ fn scan_biff_sheet(sheet: &[u8]) -> Result<u64, Error> { cf = 0; } if rl >= 1 && cl >= 1 { - if rl - 1 < rf { + // calamine computes (last - first + 1) on u32: underflows + // when either range is empty. + if rl - 1 < rf || cl - 1 < cf { return Err(err("malformed XLS DIMENSIONS record")); } reserved = reserved @@ -801,17 +992,37 @@ fn scan_biff_sheet(sheet: &[u8]) -> Result<u64, Error> { } } // Cell records starting with (row, col). - 0x0203 | 0x0204 | 0x00D6 | 0x0205 | 0x027E | 0x00FD | 0x0006 => { + 0x0203 | 0x0204 | 0x00D6 | 0x0205 | 0x027E | 0x00FD => { + if let (Some(r), Some(c)) = (read_u16(data, 0), read_u16(data, 2)) { + see(r.into(), c.into()); + } + } + // FORMULA: calamine decompiles `rgce` (cce at offset 20) from 22 bytes on. + 0x0006 if data.len() >= 20 => { + if data.len() < 22 { + return Err(err("truncated XLS FORMULA record")); + } + if data[20..22] != [0, 0] { + neutralise(at + 20); + } if let (Some(r), Some(c)) = (read_u16(data, 0), read_u16(data, 2)) { see(r.into(), c.into()); } } + // MERGECELLS: count, then count × 8-byte ranges (unchecked in calamine). + 0x00E5 => { + let n = read_u16(data, 0).unwrap_or(0) as usize; + if data.len() < 2 + 8 * n { + return Err(err("malformed XLS MERGECELLS record")); + } + } // MULRK: row, first col, …, last col. 0x00BD if data.len() >= 6 => { let r = read_u16(data, 0).unwrap_or(0); let first = read_u16(data, 2).unwrap_or(0); let last = read_u16(data, data.len() - 2).unwrap_or(0); - if last < first { + // calamine computes (last - first + 1) on u16. + if last < first || last - first == u16::MAX { return Err(err("malformed XLS MULRK record")); } see(r.into(), first.into()); @@ -1079,6 +1290,6 @@ mod tests { s.extend_from_slice(&0x00BDu16.to_le_bytes()); s.extend_from_slice(&6u16.to_le_bytes()); s.extend_from_slice(&[0, 0, 5, 0, 1, 0]); - assert!(scan_biff_sheet(&s).is_err()); + assert!(scan_biff_sheet(&s, |_| {}).is_err()); } } diff --git a/crates/markkit/tests/document_security.rs b/crates/markkit/tests/document_security.rs index 9e594db..f353025 100644 --- a/crates/markkit/tests/document_security.rs +++ b/crates/markkit/tests/document_security.rs @@ -248,6 +248,193 @@ fn xls_bad_sheet_offset_rejected() { assert!(res.is_err()); } +#[cfg(feature = "spreadsheet")] +type Records = Vec<(u16, Vec<u8>)>; + +/// One-sheet BIFF8 workbook with raw records spliced in right after the globals +/// BOF and the sheet BOF (BOUNDSHEET offset patched). +#[cfg(feature = "spreadsheet")] +fn xls_with_records(globals: &[(u16, Vec<u8>)], sheet: &[(u16, Vec<u8>)]) -> Vec<u8> { + let enc = |recs: &[(u16, Vec<u8>)]| { + let mut out = Vec::new(); + for (typ, data) in recs { + out.extend_from_slice(&typ.to_le_bytes()); + out.extend_from_slice(&(data.len() as u16).to_le_bytes()); + out.extend_from_slice(data); + } + out + }; + let mut stream = biff8_workbook(&[XlsSheet { + name: "S", + dims: None, + cells: vec![XlsCell::Num(0, 0, 1.0)], + }]); + // BOF record = 4-byte header + 16 bytes. + let g = enc(globals); + stream.splice(20..20, g.iter().copied()); + let bs = stream.windows(2).position(|w| w == [0x85, 0x00]).unwrap() + 4; + let pos = u32::from_le_bytes(stream[bs..bs + 4].try_into().unwrap()) + g.len() as u32; + stream[bs..bs + 4].copy_from_slice(&pos.to_le_bytes()); + let at = pos as usize + 20; + stream.splice(at..at, enc(sheet)); + xls_from_stream(&stream) +} + +/// Malformed BIFF records calamine reads without bounds checks (found by the +/// `xls_biff` fuzz target). The pre-scan must reject them before calamine +/// panics: a caught panic is not enough under `panic = "abort"`. +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_truncated_records_rejected_before_calamine() { + let mut sst_flags = vec![0u8; 8]; + sst_flags.extend_from_slice(&[1, 0, 0x08]); // fRichSt, but no cRun bytes + let mut sst_cont = vec![0u8; 8]; + sst_cont.extend_from_slice(&[5, 0, 0, b'a', b'b']); // 5 chars, 2 present + let mut lbl = vec![0u8; 14]; + lbl[4] = 100; // cce > record length + let cases: Vec<(&str, Records, Records)> = vec![ + ("FILEPASS empty", vec![(0x002F, vec![])], vec![]), + ("CODEPAGE empty", vec![(0x0042, vec![])], vec![]), + ("DATEMODE 1 byte", vec![(0x0022, vec![1])], vec![]), + ("BOF 1 byte", vec![(0x0809, vec![6])], vec![]), + ("BOUNDSHEET 5 bytes", vec![(0x0085, vec![0; 5])], vec![]), + ("LBL short", vec![(0x0018, vec![0; 5])], vec![]), + ("LBL cce overrun", vec![(0x0018, lbl)], vec![]), + ("EXTERNSHEET empty", vec![(0x0017, vec![])], vec![]), + ("EXTERNSHEET 1 byte", vec![(0x0017, vec![1])], vec![]), + ("SST missing cRun", vec![(0x00FC, sst_flags)], vec![]), + ( + "SST empty CONTINUE", + vec![(0x00FC, sst_cont), (0x003C, vec![])], + vec![], + ), + ("MERGECELLS overrun", vec![], vec![(0x00E5, vec![5, 0])]), + ( + "MULRK full column range", + vec![], + vec![(0x00BD, vec![0, 0, 0, 0, 0xFF, 0xFF])], + ), + ( + "DIMENSIONS zero columns", + vec![], + vec![( + 0x0200, + [0u32.to_le_bytes(), 1u32.to_le_bytes()] + .concat() + .into_iter() + .chain([3, 0, 3, 0, 0, 0]) + .collect(), + )], + ), + ]; + let m = Markkit::builder().build(); + for (what, g, s) in cases { + let res = m.extract_document_sync(&xls_with_records(&g, &s), DocumentHint::default()); + let err = res.expect_err(what); + assert!(!err.to_string().contains("panicked"), "{what}: {err}"); + } +} + +/// calamine skips embedded substreams (charts) and keeps reading after their +/// EOF, so the pre-scan must too, or records after a chart bypass it. +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_records_after_embedded_substream_scanned() { + let mut bof = vec![0x00, 0x06, 0x20, 0x00]; + bof.resize(16, 0); + let sheet = vec![(0x0809, bof), (0x000A, vec![]), (0x00E5, vec![5, 0])]; + let err = Markkit::builder() + .build() + .extract_document_sync(&xls_with_records(&[], &sheet), DocumentHint::default()) + .unwrap_err(); + assert!(!err.to_string().contains("panicked"), "{err}"); +} + +/// FORMULA record at (1, 0) with cached number 2.0 and the given `rgce` +/// (CellParsedFormula: cce + tokens). +#[cfg(feature = "spreadsheet")] +fn formula(rgce: &[u8]) -> (u16, Vec<u8>) { + let mut d = Vec::new(); + d.extend_from_slice(&[1, 0, 0, 0, 0, 0]); + d.extend_from_slice(&2.0f64.to_le_bytes()); + d.extend_from_slice(&[0; 6]); + d.extend_from_slice(rgce); + (0x0006, d) +} + +/// calamine's formula decompiler indexes tokens without bounds checks. markkit +/// renders cached values only, so token streams are neutralised and the +/// cached value still renders. +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_malformed_formula_tokens_neutralised() { + let cases: Vec<(&str, Vec<u8>)> = vec![ + ("cce overrun", vec![0xFF, 0x00]), + ("PtgName index 0", vec![5, 0, 0x23, 0, 0, 0, 0]), + ("PtgRef truncated", vec![1, 0, 0x24]), + ("PtgArea3d truncated", vec![3, 0, 0x3B, 0, 0]), + ("PtgStr overrun", vec![3, 0, 0x17, 200, 0]), + ]; + let m = Markkit::builder().build(); + for (what, rgce) in cases { + let bytes = xls_with_records(&[], &[formula(&rgce)]); + let out = m + .extract_document_sync(&bytes, DocumentHint::default()) + .unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(out.markdown.contains('2'), "{what}: {}", out.markdown); + } +} + +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_truncated_formula_record_rejected() { + // 20-byte header present, cce missing. + let (typ, mut d) = formula(&[]); + d.push(0); + let err = Markkit::builder() + .build() + .extract_document_sync(&xls_with_records(&[], &[(typ, d)]), DocumentHint::default()) + .unwrap_err(); + assert!(!err.to_string().contains("panicked"), "{err}"); +} + +/// Defined names (LBL) whose single token is cut short. +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_truncated_defined_name_token_rejected() { + for ptg in [0x3A, 0x3B, 0x3C, 0x3D] { + let mut lbl = vec![0u8; 14]; + lbl[3] = 1; // cch + lbl[4] = 1; // cce + lbl.extend_from_slice(&[0, b'N', ptg]); + let err = Markkit::builder() + .build() + .extract_document_sync( + &xls_with_records(&[(0x0018, lbl)], &[]), + DocumentHint::default(), + ) + .unwrap_err(); + assert!(!err.to_string().contains("panicked"), "ptg {ptg:#x}: {err}"); + } +} + +#[cfg(feature = "spreadsheet")] +#[test] +fn xls_valid_records_still_accepted() { + // 16-byte DIMENSIONS (some writers pad), EXTERNSHEET with zero XTIs. + let mut dims = Vec::new(); + for v in [0u32, 1] { + dims.extend_from_slice(&v.to_le_bytes()); + } + dims.extend_from_slice(&[0, 0, 1, 0, 0, 0, 0, 0]); + let bytes = xls_with_records(&[(0x0017, vec![0, 0])], &[(0x0200, dims)]); + let out = Markkit::builder() + .build() + .extract_document_sync(&bytes, DocumentHint::default()) + .unwrap(); + assert!(out.markdown.contains('1'), "{}", out.markdown); +} + #[cfg(feature = "spreadsheet")] #[test] fn ods_repeated_rows_and_columns_rejected() { diff --git a/justfile b/justfile index aa12863..b45cc65 100644 --- a/justfile +++ b/justfile @@ -89,6 +89,16 @@ extract url: extract-file path: cargo run -p markkit-cli -- extract --file {{path}} +# ─── Fuzzing (nightly + cargo-fuzz) ────────────────────────── + +# Fuzz one target (html, document, docx_xml, xlsx_xml, ods_xml, xls_biff, render_ir) +fuzz target secs="60": + cd crates/markkit && mkdir -p fuzz/corpus/{{target}} && cargo +nightly fuzz run -O {{target}} fuzz/corpus/{{target}} $(ls -d fuzz/seeds/{{target}} 2>/dev/null) -- -max_total_time={{secs}} -max_len=65536 + +# Build every fuzz target (no run) +fuzz-build: + cd crates/markkit && cargo +nightly fuzz build -O + # ─── Benchmarks ────────────────────────────────────────────── # Run the benchmark harness and save JSON/Markdown results to crates/markkit-bench/results/ diff --git a/knowledge/log.md b/knowledge/log.md index ac7ffbb..9abdc77 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-27 +* **Fuzzing**: new [Fuzzing](security/fuzzing.md) concept. Eleven cargo-fuzz targets (`crates/markkit/fuzz`, every parser incl. XLSB, PDF, Markdown, CSV, plus the renderer) assert the S17 oracle and run nightly (`fuzz.yml`). The `xls_biff` target found calamine panicking on a dozen truncated BIFF record shapes and in its formula decompiler; the XLS pre-scan now rejects them, neutralises formula tokens and hands calamine a container rebuilt from the scanned stream. See [Threat Model](security/threat-model.md) T6c. * **XLSB support (#11)**: Excel binary workbooks are read by a new bounds-checked BIFF12 reader (`spreadsheet/xlsb.rs`) instead of being rejected; calamine's XLSB reader was not used because it panics on malformed records. Same caps, hidden-sheet and macro rules as XLSX. See [Format Support](formats/format-support.md) and [Threat Model](security/threat-model.md) T6c. * **Over-escaping fix**: line-start `#`, `+` and ordered markers are escaped only when followed by whitespace or the end of the text run, so `4.6 out of 5` no longer renders `4\.6` (eval gap). `> - =` stay always escaped. See [Threat Model](security/threat-model.md) T1, [Evaluation Harness](operations/eval.md). * **MCP server**: `markkit mcp` serves `MarkkitTool` over MCP stdio (initialize, ping, tools/list, tools/call); line size capped from `--max-size`, tool failures as `isError` results. New [MCP Server](integrations/mcp-server.md) concept; MCP messages row in the [Threat Model](security/threat-model.md). diff --git a/knowledge/operations/testing.md b/knowledge/operations/testing.md index ed92cbd..e75df02 100644 --- a/knowledge/operations/testing.md +++ b/knowledge/operations/testing.md @@ -44,6 +44,8 @@ each security test states exactly which bytes it feeds the parser. - **Property** (`proptest`): arbitrary input never panics, output never contains `<script>`/`<style>`, extraction is deterministic, limits hold, source indentation between blocks never changes output +- **Fuzz** (cargo-fuzz, nightly): seven targets assert the S17 output oracle; see + [Fuzzing](../security/fuzzing.md) - **Security**: one or more tests per mitigated threat; the [Threat Model](../security/threat-model.md) maps threats to test names - **Benchmarks**: `just bench` runs `markkit-bench` (ops/s, throughput, allocations) @@ -64,7 +66,8 @@ each security test states exactly which bytes it feeds the parser. `ci.yml` runs fmt, clippy, rustdoc (`-D warnings`), the knowledge bundle check, `cargo audit` + `cargo deny`, `cargo test --workspace` (default and all features), the `docx,spreadsheet,markdown` no-HTTP build with a reqwest leak check, and every example. -`nightly.yml` repeats build + test on nightly Rust daily; `coverage.yml` uploads +`nightly.yml` repeats build + test on nightly Rust daily; `fuzz.yml` runs every +cargo-fuzz target nightly and uploads crashes; `coverage.yml` uploads tarpaulin coverage (`--engine llvm`; ptrace segfaults on threaded socket tests). All must pass before merge. ## See also diff --git a/knowledge/security/fuzzing.md b/knowledge/security/fuzzing.md new file mode 100644 index 0000000..419a627 --- /dev/null +++ b/knowledge/security/fuzzing.md @@ -0,0 +1,88 @@ +--- +type: Test Strategy +title: Fuzzing +description: cargo-fuzz targets, the output-safety oracle they assert, seeds, the nightly workflow and how to triage a crash. +tags: + - markkit + - security + - testing +--- + +# Fuzzing + +Coverage-guided fuzzing (cargo-fuzz / libFuzzer) of every parser and the +renderer. Complements the proptests in `tests/` (fast, run on every PR) with +long nightly runs that find what generators miss. See +[Threat Model](threat-model.md) for the threats each target covers. + +## Decisions + +- **Oracle, not just "no panic".** Every target checks rendered Markdown with + pulldown-cmark (GFM): no `Html` / `InlineHtml` event, no link or image with a + non-allowlisted scheme (invariant S17). The oracle lives in + `fuzz/src/lib.rs` (`check_markdown`) and mirrors `tests/output_safety.rs`. +- **Structure-aware targets.** Raw bytes rarely get past a ZIP or OLE2 header, + so most targets fuzz the inner XML / BIFF stream and wrap it in a valid + container with the `tests/common` builders (`#[path]`-included, one copy). +- **Separate crate.** `crates/markkit/fuzz` has its own `[workspace]` and + lockfile: nightly-only, never built by `cargo build --workspace`. +- **Inputs capped at 64 KiB** (`MAX_INPUT`): resource limits are tested + deterministically in `document_security.rs`; big inputs only slow the fuzzer. +- **Fix panics before the dependency sees the input.** `catch_unwind` in + `extract.rs` only helps where panics unwind (not the WASM build or + `panic = "abort"` embedders), and libFuzzer reports caught panics anyway. A + dependency panic found by fuzzing is fixed by rejecting or neutralising the + input first (see the XLS pre-scan in `spreadsheet.rs`), with a regression test. + +## Targets + +| Target | Input | Covers | +|--------|-------|--------| +| `html` | Raw HTML | HTML parser, hidden content, renderer (T1, T2, T9) | +| `document` | Raw bytes, no hint | Format sniffing and every parser (T6) | +| `docx_xml` | `word/document.xml` in a valid package | WordprocessingML walker | +| `xlsx_xml` | Worksheet XML in a valid workbook | XLSX streaming reader | +| `xlsb_bin` | BIFF12 worksheet stream in a valid workbook | XLSB reader (T6c) | +| `ods_xml` | `<office:spreadsheet>` content | ODS repeat pre-scan (T6b) | +| `xls_biff` | BIFF8 `Workbook` stream in a valid OLE2 file | XLS pre-scan, calamine BIFF reader (T6b-c) | +| `pdf` | Raw bytes, `.pdf` hint | PDF pre-scan, hayro parsing, text layer (T8) | +| `markdown` | Raw bytes, `.md` hint | Markdown re-parse and render (T7) | +| `csv` | Raw bytes, `.csv` / `.tsv` hint | CSV/TSV reader and table caps | +| `render_ir` | Arbitrary IR tree (`arbitrary`) | Renderer on trees no parser emits yet | + +Seeds: `fuzz/seeds/<target>/` (committed, small) plus `tests/fixtures/html` +for `html`. `fuzz/corpus/` and `fuzz/artifacts/` are git-ignored. + +## Running + +```bash +just fuzz html 120 # one target, 120 s (nightly + cargo-fuzz) +just fuzz-build # compile all targets +cd crates/markkit && cargo +nightly fuzz run -O xls_biff fuzz/artifacts/xls_biff/crash-<id> # reproduce +``` + +CI: `.github/workflows/fuzz.yml` runs every target nightly (03:00 UTC, 300 s +each; `workflow_dispatch` takes a validated `duration`). Corpora persist in +the Actions cache; crashes upload as `fuzz-crashes-<target>` artifacts. + +## Triage + +1. Reproduce from the artifact, minimise with `cargo fuzz tmin`. +2. Write the failing regression test first (`output_safety.rs` for renderer + findings, `document_security.rs` for parser findings). +3. Fix the root cause in markkit; for dependency panics, reject or neutralise + the triggering input in a pre-scan. +4. Note the finding class in [Threat Model](threat-model.md) and `log.md`. + +## Findings so far + +- Renderer (S17): code block in a table cell, blocks inside headings, empty + destinations, 64-char scheme probe, inline runs next to blocks, list + indentation narrower than the ordered marker, repeated hard breaks, nested + links, adjacent code spans merging. +- XLS (calamine 0.37): unchecked reads in FILEPASS, CODEPAGE, DATEMODE, BOF, + BOUNDSHEET, LBL (incl. defined-name tokens), EXTERNSHEET, SST (cRun / + cbExtRst, empty CONTINUE), MERGECELLS; u16/u32 underflow in MULRK and + DIMENSIONS; dozens of unchecked reads in the formula decompiler (formula + tokens are now neutralised); records after embedded chart substreams escaped + the pre-scan. diff --git a/knowledge/security/index.md b/knowledge/security/index.md index 44cb31a..ba83898 100644 --- a/knowledge/security/index.md +++ b/knowledge/security/index.md @@ -1,4 +1,5 @@ # Security * [Security Model](security-model.md) - Threat categories and security invariants summary for untrusted document input. +* [Fuzzing](fuzzing.md) - cargo-fuzz targets, the output-safety oracle they assert, seeds, the nightly workflow and how to triage a crash. * [Threat Model](threat-model.md) - Attack surface, threats T1-T9 with mitigations and test coverage, resource limits, and security invariants. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 881ac3f..2e9f316 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -266,8 +266,14 @@ process, or smuggling content past a human reviewer. `truncated_sheet` warnings. #### T6c: Parser crashes on malformed input -- XLS pre-scan rejects records that trip calamine's unchecked arithmetic (inverted - DIMENSIONS, inverted MULRK column ranges, out-of-range sheet offsets). +- XLS pre-scan rejects records that trip calamine's unchecked reads and arithmetic + (inverted or empty DIMENSIONS, inverted or full-range MULRK, out-of-range sheet + offsets, truncated globals records, SST strings cut mid-header or followed by an + empty CONTINUE, MERGECELLS overruns, short defined-name tokens), following calamine + past embedded chart substreams. Formula token streams are neutralised (`cce = 0`; + only cached values render). calamine reads a container rebuilt from the exact + stream scanned, so its own CFB reader cannot see different bytes. +- Found and kept fixed by nightly fuzzing ([Fuzzing](fuzzing.md), `xls_biff` target). - XML: depth capped at 256, tree built iteratively, unclosed elements tolerated. - Parsers run under `catch_unwind` (defence in depth; only effective with `panic = "unwind"`, which the workspace release profile uses so the CLI and the @@ -302,6 +308,9 @@ process, or smuggling content past a human reviewer. `xlsx_bomb_in_unreferenced_entry_rejected`, `max_input_size_honoured_for_office_documents` - `xls_huge_dimensions_rejected`, `xls_far_apart_cells_rejected`, `xls_inverted_dimensions_rejected_not_panicking`, `xls_bad_sheet_offset_rejected`, + `xls_truncated_records_rejected_before_calamine`, `xls_records_after_embedded_substream_scanned`, + `xls_malformed_formula_tokens_neutralised`, `xls_truncated_formula_record_rejected`, + `xls_truncated_defined_name_token_rejected`, `xls_valid_records_still_accepted`, `ods_repeated_rows_and_columns_rejected`, `spreadsheet_integration::xlsx_sparse_far_cell_does_not_allocate_grid` - `docx_billion_laughs_not_expanded`, `docx_external_entity_not_resolved`,