From b87e8c294f924908fb6f0e1c362c60363385d39e Mon Sep 17 00:00:00 2001 From: Nader Bennour Date: Fri, 3 Apr 2026 15:09:33 +0200 Subject: [PATCH] Add fuzz testing and diverse XLSX test corpus - 4 cargo-fuzz targets covering all reader entry points (read_xlsx, read_single_sheet, read_sheet_names, read_document_properties) - 23 generated fixture files: valid corpus (16 files testing different features, data types, Unicode, rich text, sparse rows, multiple sheets, etc.) and malformed corpus (7 files: truncated ZIP, empty ZIP, missing workbook, malformed XML, bad string index, etc.) - test_corpus.py: 30 Python tests exercising the corpus (valid parse, malformed error handling, write-read roundtrip) - generate_corpus.py: reproducible fixture generation script - CI workflow (fuzz.yml): weekly scheduled + on-demand + PR trigger for reader changes, with corpus caching and crash artifact upload - docs/edge-cases.md: documented known edge cases and security limits - Made reader/writer/types modules public for fuzz target access Closes #19 --- .github/workflows/fuzz.yml | 60 +++ .gitignore | 4 + docs/edge-cases.md | 96 ++++ fuzz/Cargo.toml | 40 ++ .../fuzz_read_document_properties.rs | 8 + fuzz/fuzz_targets/fuzz_read_sheet_names.rs | 8 + fuzz/fuzz_targets/fuzz_read_single_sheet.rs | 9 + fuzz/fuzz_targets/fuzz_read_xlsx.rs | 9 + src/lib.rs | 6 +- tests/fixtures/auto_filter.xlsx | Bin 0 -> 1625 bytes tests/fixtures/column_widths.xlsx | Bin 0 -> 1611 bytes tests/fixtures/date_cells.xlsx | Bin 0 -> 2049 bytes tests/fixtures/defined_names.xlsx | Bin 0 -> 1686 bytes tests/fixtures/document_properties.xlsx | Bin 0 -> 2322 bytes tests/fixtures/empty_workbook.xlsx | Bin 0 -> 1570 bytes tests/fixtures/empty_zip.xlsx | Bin 0 -> 22 bytes tests/fixtures/freeze_panes.xlsx | Bin 0 -> 1657 bytes tests/fixtures/huge_row_gap.xlsx | Bin 0 -> 1612 bytes tests/fixtures/inline_strings.xlsx | Bin 0 -> 1656 bytes tests/fixtures/large_shared_strings.xlsx | Bin 0 -> 4718 bytes tests/fixtures/malformed_xml.xlsx | Bin 0 -> 1618 bytes tests/fixtures/many_data_types.xlsx | Bin 0 -> 1993 bytes tests/fixtures/merged_cells.xlsx | Bin 0 -> 1649 bytes tests/fixtures/missing_workbook.xlsx | Bin 0 -> 658 bytes tests/fixtures/multiple_sheets.xlsx | Bin 0 -> 2977 bytes tests/fixtures/negative_row_number.xlsx | Bin 0 -> 1595 bytes tests/fixtures/rich_text_strings.xlsx | Bin 0 -> 1975 bytes tests/fixtures/single_cell.xlsx | Bin 0 -> 1597 bytes tests/fixtures/sparse_rows.xlsx | Bin 0 -> 1625 bytes tests/fixtures/truncated_zip.xlsx | Bin 0 -> 798 bytes tests/fixtures/unicode_strings.xlsx | Bin 0 -> 2178 bytes tests/fixtures/wrong_string_index.xlsx | Bin 0 -> 1909 bytes tests/generate_corpus.py | 420 ++++++++++++++++++ tests/test_corpus.py | 236 ++++++++++ 34 files changed, 893 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 docs/edge-cases.md create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/fuzz_read_document_properties.rs create mode 100644 fuzz/fuzz_targets/fuzz_read_sheet_names.rs create mode 100644 fuzz/fuzz_targets/fuzz_read_single_sheet.rs create mode 100644 fuzz/fuzz_targets/fuzz_read_xlsx.rs create mode 100644 tests/fixtures/auto_filter.xlsx create mode 100644 tests/fixtures/column_widths.xlsx create mode 100644 tests/fixtures/date_cells.xlsx create mode 100644 tests/fixtures/defined_names.xlsx create mode 100644 tests/fixtures/document_properties.xlsx create mode 100644 tests/fixtures/empty_workbook.xlsx create mode 100644 tests/fixtures/empty_zip.xlsx create mode 100644 tests/fixtures/freeze_panes.xlsx create mode 100644 tests/fixtures/huge_row_gap.xlsx create mode 100644 tests/fixtures/inline_strings.xlsx create mode 100644 tests/fixtures/large_shared_strings.xlsx create mode 100644 tests/fixtures/malformed_xml.xlsx create mode 100644 tests/fixtures/many_data_types.xlsx create mode 100644 tests/fixtures/merged_cells.xlsx create mode 100644 tests/fixtures/missing_workbook.xlsx create mode 100644 tests/fixtures/multiple_sheets.xlsx create mode 100644 tests/fixtures/negative_row_number.xlsx create mode 100644 tests/fixtures/rich_text_strings.xlsx create mode 100644 tests/fixtures/single_cell.xlsx create mode 100644 tests/fixtures/sparse_rows.xlsx create mode 100644 tests/fixtures/truncated_zip.xlsx create mode 100644 tests/fixtures/unicode_strings.xlsx create mode 100644 tests/fixtures/wrong_string_index.xlsx create mode 100644 tests/generate_corpus.py create mode 100644 tests/test_corpus.py diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..da5e966 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,60 @@ +name: Fuzz + +on: + # Run on-demand + workflow_dispatch: + inputs: + duration: + description: "Fuzz duration in seconds per target" + required: false + default: "300" + # Weekly scheduled run + schedule: + - cron: "0 3 * * 1" # Every Monday at 03:00 UTC + # Also run on PRs that touch the reader + pull_request: + paths: + - "src/reader/**" + - "fuzz/**" + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - fuzz_read_xlsx + - fuzz_read_single_sheet + - fuzz_read_sheet_names + - fuzz_read_document_properties + steps: + - uses: actions/checkout@v6 + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Cache fuzz corpus + uses: actions/cache@v4 + with: + path: fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.target }}-${{ github.sha }} + restore-keys: | + fuzz-corpus-${{ matrix.target }}- + + - name: Run fuzzer + run: | + DURATION=${{ github.event.inputs.duration || '60' }} + cargo +nightly fuzz run ${{ matrix.target }} -- \ + -max_total_time=$DURATION \ + -max_len=65536 + + - name: Upload crash artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-crash-${{ matrix.target }} + path: fuzz/artifacts/${{ matrix.target }} diff --git a/.gitignore b/.gitignore index 6ad2ff2..bd02022 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,10 @@ env/ *.swp *.swo +# Fuzzing artifacts (keep seed corpus, ignore runtime output) +fuzz/artifacts/ +fuzz/corpus/ + # OS .DS_Store Thumbs.db diff --git a/docs/edge-cases.md b/docs/edge-cases.md new file mode 100644 index 0000000..ab12e5d --- /dev/null +++ b/docs/edge-cases.md @@ -0,0 +1,96 @@ +# Known Edge Cases and Limitations + +This document records edge cases discovered through fuzzing and corpus +testing, along with the reader's behavior in each case. + +## Security Limits + +The reader enforces hard limits to prevent denial-of-service: + +| Limit | Value | Effect | +|-------|-------|--------| +| Max ZIP entry size | 256 MB (decompressed) | Returns `XlsxError` | +| Max shared strings | 2,000,000 | Returns `XlsxError` | +| Max rows per sheet | 1,048,576 (Excel limit) | Returns `XlsxError` | + +## ZIP / Archive Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Truncated ZIP | Clean `ZipError` | +| Empty ZIP (no entries) | Error: missing `xl/workbook.xml` | +| Non-ZIP data (random bytes) | Clean `ZipError` | +| Missing `xl/workbook.xml` | Error: `InvalidStructure` | +| Missing `_rels/.rels` | Falls back gracefully | +| ZIP bomb (large compression ratio) | Caught by entry size limit | + +## XML Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Malformed XML (unclosed tags) | Partial parse; may return truncated data | +| Missing `` element | Empty rows returned | +| Unknown XML elements | Ignored (forward-compatible) | +| XML with BOM | Handled by quick-xml | +| Very deeply nested XML | Parsed normally (no depth limit) | + +## Cell & Data Type Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Shared string index out of bounds | Returns `"[invalid string index]"` | +| Empty cell element `` | Returns `Empty` | +| Cell with no `` child | Returns `Empty` | +| Boolean cell with value not 0 or 1 | Treated as truthy/falsy | +| Inline string with rich text runs | Concatenated plain text | +| Formula with cached value | Returns `Formula { formula, cached_value }` | +| Error cell (`t="e"`) | Returns the error string (e.g. `"#REF!"`) | +| Number format code detection | Date serial numbers with date format codes → `Date` | + +## Row & Structure Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Row number 0 | Treated as row 0 (no crash) | +| Sparse rows (gaps in numbering) | Gaps filled with empty rows | +| Row at max (1,048,576) | Accepted (at limit) | +| Duplicate row numbers | Last row wins | +| Columns beyond XFD (16,384) | Parsed if present | +| Merged cells with no data in merge area | Merge range recorded, cells empty | + +## Sheet & Workbook Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Hidden/veryHidden sheets | Parsed with `state` field set | +| Sheet with no rows | Empty `rows` list | +| 100+ sheets in one workbook | All parsed | +| Sheet name with special characters | Preserved as-is | +| Defined names with `hidden="1"` | Included in output | +| Missing `xl/_rels/workbook.xml.rels` | Error: cannot resolve sheet paths | + +## File Source Compatibility + +The test corpus includes files structured to match output from: + +- **Microsoft Excel** (standard OOXML) +- **LibreOffice Calc** (may use different XML namespaces) +- **Google Sheets** (export as .xlsx) +- **opensheet-core writer** (roundtrip testing) +- **Programmatically generated** (minimal valid XLSX) + +## Fuzzing + +Fuzz targets exercise all reader entry points: + +- `fuzz_read_xlsx` — Full workbook parse +- `fuzz_read_single_sheet` — Single sheet extraction +- `fuzz_read_sheet_names` — Sheet name enumeration +- `fuzz_read_document_properties` — Document metadata parse + +Run locally: +```bash +cargo +nightly fuzz run fuzz_read_xlsx -- -max_total_time=60 +``` + +CI runs fuzzing weekly and on PRs touching `src/reader/` or `fuzz/`. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..427e38c --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "opensheet-core-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.opensheet-core] +path = ".." +default-features = false + +# --- Fuzz targets --------------------------------------------------- + +[[bin]] +name = "fuzz_read_xlsx" +path = "fuzz_targets/fuzz_read_xlsx.rs" +doc = false + +[[bin]] +name = "fuzz_read_single_sheet" +path = "fuzz_targets/fuzz_read_single_sheet.rs" +doc = false + +[[bin]] +name = "fuzz_read_sheet_names" +path = "fuzz_targets/fuzz_read_sheet_names.rs" +doc = false + +[[bin]] +name = "fuzz_read_document_properties" +path = "fuzz_targets/fuzz_read_document_properties.rs" +doc = false + +[workspace] +members = ["."] diff --git a/fuzz/fuzz_targets/fuzz_read_document_properties.rs b/fuzz/fuzz_targets/fuzz_read_document_properties.rs new file mode 100644 index 0000000..7b9b3a9 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_read_document_properties.rs @@ -0,0 +1,8 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Cursor; + +fuzz_target!(|data: &[u8]| { + let cursor = Cursor::new(data); + let _ = opensheet_core::reader::xlsx::read_document_properties(cursor); +}); diff --git a/fuzz/fuzz_targets/fuzz_read_sheet_names.rs b/fuzz/fuzz_targets/fuzz_read_sheet_names.rs new file mode 100644 index 0000000..2bd428f --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_read_sheet_names.rs @@ -0,0 +1,8 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Cursor; + +fuzz_target!(|data: &[u8]| { + let cursor = Cursor::new(data); + let _ = opensheet_core::reader::xlsx::read_sheet_names(cursor); +}); diff --git a/fuzz/fuzz_targets/fuzz_read_single_sheet.rs b/fuzz/fuzz_targets/fuzz_read_single_sheet.rs new file mode 100644 index 0000000..75f2f14 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_read_single_sheet.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Cursor; + +fuzz_target!(|data: &[u8]| { + let cursor = Cursor::new(data); + // Try reading the first sheet by index. + let _ = opensheet_core::reader::xlsx::read_single_sheet(cursor, None, Some(0)); +}); diff --git a/fuzz/fuzz_targets/fuzz_read_xlsx.rs b/fuzz/fuzz_targets/fuzz_read_xlsx.rs new file mode 100644 index 0000000..e647778 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_read_xlsx.rs @@ -0,0 +1,9 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +use std::io::Cursor; + +fuzz_target!(|data: &[u8]| { + let cursor = Cursor::new(data); + // We don't care about the result — only that it doesn't panic or hang. + let _ = opensheet_core::reader::xlsx::read_xlsx(cursor); +}); diff --git a/src/lib.rs b/src/lib.rs index 45a9043..aa04375 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,9 +6,9 @@ use pyo3::types::{ use std::fs::File; use std::io::{BufReader, BufWriter}; -mod reader; -mod types; -mod writer; +pub mod reader; +pub mod types; +pub mod writer; use types::CellValue; use writer::xlsx::StreamingXlsxWriter; diff --git a/tests/fixtures/auto_filter.xlsx b/tests/fixtures/auto_filter.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c4e9bba7b756f2b656614b42d10ab27138ea2b0a GIT binary patch literal 1625 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83+`apZFFyc! zvmfYP350ixGg4DaiuJ*iAt-)J1J3#Z{k`wA$i(+&VkZjBy5!@W;jVXp*=6N*wJ@!@$5f$KeYD= zeU-j3kNI{s+ri!S!s0J;-bwX-ZryzB^yWA0&-e2CxQbfor02S{H$C_{{h4%tHzSh> zGw$*a7&Kte01PLrr6IZj=;Z-KI|D-lqan};q|yOhBYJj2XygW##keycx;f|x8DY*( oh&jl~8eKDbvOs8l!3=f;NHazv3GilR1F2*MLRX+K|FM900Iv0kC;$Ke literal 0 HcmV?d00001 diff --git a/tests/fixtures/column_widths.xlsx b/tests/fixtures/column_widths.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a13255c68dca6bd6a4d24f36d7ddc0551916bd3a GIT binary patch literal 1611 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83+{ErJL6?Bu zbOL%;0^!}_jMUVUVtp`W2#Vj_{)2gk6?od-S8_e6e!L`fc^1t_s2n>hDMCU4-f z(>)j%Uex(=E?f5IghmIhwmHkwU0$)h=Dk|DsZl-OuyXRxS$Q78o0i2Z+CSQsIq!nB zc*E6Q7L)V>E5dUcK0Mp^?C96ZfDNhfciEr5Sbx7)>;wOX-;Ce*9|XMruUt^Oa?$k>lULiU)WzGKnzbF7beY0R|1gkilC1 zp&Ni+0zkAgFf=e40*yc_570HDXE20DZeZz)JG-HqgPwE|=KO@1gPf4jHKQj4gyt8_ bU`K#7V(6r1^V&>3y22*F{FWl literal 0 HcmV?d00001 diff --git a/tests/fixtures/date_cells.xlsx b/tests/fixtures/date_cells.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..660547a4d88929fa1763770df056ebdcbed3a6b2 GIT binary patch literal 2049 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83Tz^fsI}d=~ zoCWl*1j4(;8L6oy#rj~%5EQ??6Ylzf{Qds7NZ`B3S(%U0y^M21Y#41CrtZpinH*C1 z;=xz!0^RQ(3$A6eCys=fI0o1b00Rl(@QUU4AHo ze5$HmdSmYA`hu13_Z~LexxHcIoJvV|jXj*abxN75Pw704(l{`)*oxCFTdc+4-RgVU zarv1uZ|0f(dU$TdO{u4P(+=O<^zZPe3g3(NPrjM&36yC+y}IGf?|)&xIZ;C;gZ;)6 zCt!%MFf%alLPDjuq%tS97!(vsCvD6-tRT?#-HtWMf5T;yRa&Ga# zCT7OYM|QRR(o6lUw|MQn^Rf8<@xmPu^S3IgzpP1Lu;i!BVi%`m7xv$i_T|35sjO;O zAP^%tLG|s@<*#3@`>-&)sCa^rfJ%e@OGTF7M`q;n1r^(yhcUg|ax7D>;N`b7(ds)t zTwEd=opQ4)^B$wCj7;g;X7yf6rvr8wQ&o)G#2X)Fd7eB`ZKm|C>AuhFnF3QnS-p9d zY-L*)8MuP$zoTEw!b$vJ7hAL(yrNlZsJn=>b=3wNmX`~Tv3ma8w!OmYfsNa_fbZU4 z?|rn3xAEa|?qGl4Bm9Hma-F;8wPn6K*-L_ETYUZgZPm9serZ#VavoGYDjZ~0bjh({)VgenL2lkmc{?i{|XFjMkWzv z+?5S5#KE8e7zJ3X9&`iH3txzK28ISkL!c2zMKQWY^s)`1ksDa?;V$XW%|S0U5a#@Z zn1ft~ple3Yg9yzpfawqD2#{us9EolUdYVO;@{a{<3L*suc(byBq}hNF6vo+L9ssi7 BHwOR! literal 0 HcmV?d00001 diff --git a/tests/fixtures/defined_names.xlsx b/tests/fixtures/defined_names.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..743749bf2554af2ff878fefdb1ea677d3bb81486 GIT binary patch literal 1686 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>0QeH`R{KYEy*_d z?e=`?QlU+STs$kc1zp_L^7QxPuet{EES?K>6FIHF7_4qMcYoD6XPKhy_p?P5ry)G_gPDU)Jwky;jY*uC(h=me_o@8QZpA=)N5D zvf-EDO7VpGQ>J(vxV87&$8$4wrx<-sBk)MUfeRsQ-gG}%ZX0#>nWZPQoAmc=G%8%1>V@7}A+V|!i7)EV#b zU;T++e5RRpYVRT2lHFoFg%cD%t!;n(`{`5B94~o%&KB|8S6F^h-bzq3m~oA* z&r%)tY*U{p0p*(nuAkJfOkA0}FEZBX;f9%)a=zzQZ{SpRPM){%VBb%B3(4aWFLWLn z3r>%ZTPErK*Jb$*jUUSjZ|^h~`=WRHZb%XPz1H@M>J?YZ&#k+6GhlPxm0w#z_W!NE z@*kLj7@0(vaTlII--AH|FkN9SO3@8KFH9iX85kNE4S_}=6)or*(eo-oBR4QTSggHMU<{;+@bj|27kI?MI0(JyQGe$ZH@MdKLsbmF0SD-JgSwTDiLfoI6 literal 0 HcmV?d00001 diff --git a/tests/fixtures/document_properties.xlsx b/tests/fixtures/document_properties.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..42b28b5002b19c635cbb1e59d88894dc9ca3f6e0 GIT binary patch literal 2322 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/empty_zip.xlsx b/tests/fixtures/empty_zip.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..15cb0ecb3e219d1701294bfdf0fe3f5cb5d208e7 GIT binary patch literal 22 NcmWIWW@Tf*000g10H*)| literal 0 HcmV?d00001 diff --git a/tests/fixtures/freeze_panes.xlsx b/tests/fixtures/freeze_panes.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fc771e1bb88355f49d0d3d2ec935f1ee82ff7eed GIT binary patch literal 1657 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83+@<}~{6P79 zB@jy>yjz@+np#q<52g%3@!K14+7Fo7_dM5Lvp0CVr9z11x&UpzJ6v~qH)pQ-rCRM7 zwNFlcsjj=uT9emb&s4bQ_?_Ft7;JXXd_$B@a4F%nUL TH!B-RB`XlR0(~jR3gQ6(!9|fg literal 0 HcmV?d00001 diff --git a/tests/fixtures/huge_row_gap.xlsx b/tests/fixtures/huge_row_gap.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c6dcc30883dabb1c8df8d5ab81043f83310d21b8 GIT binary patch literal 1612 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83+y^PAcU=a0 zGal$&350ixGg4DaiuJ*iAt-)(Cj@2z{oQuIm}~L(tDZM^1<6czy2-hXS;9T@Op<0q z-mbfKd2GCIGMO4%>T2yLS=Jp5JUc_hVC#jPtN@RByB(&B+1sNP9wCL6> zK5#MY*|C?wY)95`H7sdWeExF5)*p~e&?e`?tji+8c|9kp%o~Zq=n|c4&ytukF;??o|H~azKj7%cTxQjesXn;WjFlewA zfanIG7Xc9M3=9p7hCm~b3IueG=s66bksDb2;?8gA=AfrtggHMU<{+nJbj|1~0ipQ? cGuROz%^2w-z?+o~q>>c~U4g#*$O7U405(2_8~^|S literal 0 HcmV?d00001 diff --git a/tests/fixtures/inline_strings.xlsx b/tests/fixtures/inline_strings.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0954ee4967464d3984544ca43960c894b4d03810 GIT binary patch literal 1656 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83Tq_?2wf{hG zJ^^}H0^!}_jMUVUVtp`W2#Vj{iRb-*{yz4;w(CUsv}w#v!Lu@xogSHee9q|fY~96K zONw|}x2xwa?K`Y;LUEJ*@9&mjSDu|Ne*&pdPOi~Q~zCg&d6-Qw?WOt(LDuHc@^)^+|1x}AI)y0 zC7`TlyC}!HgRSRSYZ95(#$4+9dU;95+DU7s%9v*A-+0HLQ!ZP*qwk`Jo%QW5$;FGw$LM7;<3H01QT~g(bQH=*0y@ zI|D-lqan};q{0JTBYJK`XygVK)wuH}x;g0S9AVB+h&jlq9$hnfszGRe!3=f;NHazn U3h-uS1F2*MLRX+KWm!Qy06OQ8`v3p{ literal 0 HcmV?d00001 diff --git a/tests/fixtures/large_shared_strings.xlsx b/tests/fixtures/large_shared_strings.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d00fbb5ca2a0bbf7e530723cbe4a02f7973b7d5e GIT binary patch literal 4718 zcmds5i91yL8$UDF>~3Z(iBVL9Nf}DE)YwU*1rzhTGFiuzr8F2?EYXCBNXb&P;Bt{{ zBSNPxwL=Jgg_ytW8+S<6&TQAjJ?zvH zy5#U$$?0n30Z!!8+qy?<|LRMf96ODPlhUAN=O-;3rQCCyZY$lmW#?1*~j*6 zp!(0!hU=<6UD+8Tt{37rt8^JDn@lQCuzVD2gsP$KakH^{uwO{k>;NuNPgUrNVTX&k z!v?3^nj5X!cSVeI+m9$2hUio`!7KFstghoKXQ-Av>uNo}=MqZMz07={cIDsa7PRio z#Y&;~1QhH_x))VHFcb@!+w*Z-m?q82d~@i96y{j?jTulOcOSxh( z@vDTx4;>^E>!V7*&S`*M6a^6%fcFbTYA^j$+V@H{><|R)O2STIE^QE8(Pr`XpOm}E3W zRItGQCOgihIb~hrAk-#S92zb6K562PpzcnO{#WQOQx$KUEmD!NJRTC@7kmtcpcbpO=b?Q`mvv&NL9NRiX$g2@hXJ`d7b3Fub)?X zGr^rHKQ8Vw-DB1@FY)qYCI>mLE0)~H?6()SSokPpoF9-<{|IJ{_b4CN`7La_tK`9GTbB3}|D?>Gf%KHDUYAtW=?I3vfk!}z}{aSHW68$Kb**x72mp@}GB z+I8q9QSAxuX(E}araHE^oecsoz4z`5jR6KbvNAbVSUH>Y4P)iXIzq1OD;_t`tHrqI zD%Q4LP#KPOV)x@74!3?z6o!8(_IIXmw^CwMrYZ*(w{N~4n)yK{?)2b|lLDdaGq%;C zXJtwuHMn4c1EVgXS?nF-R*~^=|q^KSmJ(<@Q zSegL#%*1v${~J?LiDU1DGr|gn$bKlZ~VW(!WoqNv4-W!f26q7BDfGO}{vezMC8Q@AD1aKt)@;`9O zHQR2gQ6`vYwgCizJhQzZ2<4dx)ps*2?qC$p$5mxqLqXJ?ZJG?!0zur6cr6%Eca}}O zHU)xm;x%!=yC6F9uou5&RI^5%!!L?}C1GGKu%tMs<$;$%idT#^Wx7CSDAI?n^GaF& z0+)10c1G^A{*PN0-&MBh?{xs6>;nKxFE;Qo73lSQ0N%qfD8Sp_GjM6U2ETh6=hPw{ zO|Q)|dug2dpsKn`<2id%;#SLd`#RTS|3^`5-K|Q@`r5|d#%dVNvcc88J;z&!Fy^sY zy$QH1lkjtMUsUSCXU17yhhmT>WYXMFbqtj^S{yMPqu-8H&LVSXX=(W_V@iwjv)u88 z7-El!3AN_j!sHOAIwlL}MqZd;K5jSRa%VYx6OVD^@Gz3=B4=Raa{{us7#BgB=Z-L1 z5=@MF+}T++C+#uki)-xy_iIN-<4xr{?(`&^G+KSOcxG{WatKE?S{#tHp!QA-HO{9= zwj*)4h(YCI&#@kAIA?rubgYGE5|uw$W=J^L;&`s=c6Y}q5r*(jX36;3oQg`0pLo(7 zKp9Y=p;CP{8Owp{<};L01tzM(*N@RzRN{JKDbT0Tg1YLf$k<(U`gmVu^XcWl3)Ih6 zE`eT#?trgV$F*Xx z6rHA3crIp^Gc#3I8y!h_`I5D^~|}#qr$h$^Zf+6I(hX3w~{Qy|NrEl=DF1@s}I#H aERBM{K4LrA2!K}rKoGPTuupWUrPaR#oNSu_ literal 0 HcmV?d00001 diff --git a/tests/fixtures/malformed_xml.xlsx b/tests/fixtures/malformed_xml.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6e3399e6824d0875e71b2b94d6dc412ec25037ff GIT binary patch literal 1618 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83+<%vpnQj8T z=>+ty1j4(;8L6oy#rj~%5EQ??{Rgvv{%(6;$@RGU^^$hOu0@mdV)c`ob88tMYxG9u zu%GtczJ31egH>}KK5)PMe}sRxzIoS7rP~j(BiDLso|t_hvVPsumwyXO^;y;5THCVu zZ~db-&06Py&?9z!7tfT2=TUr`zR^o>@H{bDf7rz?|ChjAzsjEUqrob7b=AyQ`9|K; zT)uXC>yE2Tw~zgQ;B-IQIoDu*U(K(lu2Q9^JS#Q`d+TsT+}moE*&?B*G+DbdVcnvg zt0c7pjGK&her7%N^5?^sPv2f>+Er=n@mJN2!NP3)?`IVbN8|&%8JR?waTk2RFad)G zU;tq)2GI>bF9;yo85kNE4S_}=6$|JZ(eoKXBR8-R#+~cX%|TDW2y=cy%t21i=$g^f g148o)X0RhbnlVyGfHx}}NF^%}x&nRqodv`L0NqD@;Q#;t literal 0 HcmV?d00001 diff --git a/tests/fixtures/many_data_types.xlsx b/tests/fixtures/many_data_types.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f29449b04ab548c9285947dc42bff04f70df2604 GIT binary patch literal 1993 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/multiple_sheets.xlsx b/tests/fixtures/multiple_sheets.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..026b293b46be644210a31fe2f53364dc6ea87723 GIT binary patch literal 2977 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 K`tlJAhz9_dv~*qo literal 0 HcmV?d00001 diff --git a/tests/fixtures/sparse_rows.xlsx b/tests/fixtures/sparse_rows.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..aaf6750f0329d774054b59de32e5e066c49e42bc GIT binary patch literal 1625 zcmWIWW@Zs#U|`^2SY6Q^!=Yp$%D~9LV8X<}APf|ZcFxZ$NzE&X52-9jEsoWz$jwBIRPT9iTFDP+}-{OsT(oy}NcP9O-zB9#WL6gJ`zw-ORWt(Tt zNv-xtY0CfUb!E=Na7XF;+y9jQj<{RkCb@@A+Gg9Ih9l;$T{qjhsDvGv7iITECV#1l z=>ldU#YOra;**+Vu3b6Jk<}viG&DBeTeQY){_2`9501W(v%YcjjNGy!dsEMh&ds}p zKTBLI>bNMyy7NKN`>)$)yfStYd3t8yL6;~ors>m_`!1a4On+EhIVZ(rleouE&X|QK zPoDZd;n;Qow~xC|x6WJmy2nZO$~n=_1v3Opcsp)J8u~vx@Z+gG*O{D2pCkSjPuBlm zJkeG8xqo~;LjX#E)#V>(-3SaQEnuK=1L^pp)SO~{JrLP@-kR@_0T0`Qxxp^?GNag9 z0-VKLu82-$Zn;vs!c#_-@z(9hf?8~Dr~ldCPBoQ2JMYC|k1&UcPY+zs%5uNWy~A|+ zTgHfvOWT4byE1aKt?--usVnN(6wbphn7li9nc5`oPW-%p&BQ3>$%SQp?)Og2@VZ~D zepRLV$!?qb#s4!8aHW=NAGn^&xF%BOUvu#FUas>?-JF0(zAF z!|!>Ep1j*sDKN2gcgKd+XV^7jzcofj&-%4-`}PU}QT};OpVaqHShVZ>52X{JG7}d=XzC z-7BZLWP;8cUfkGvzW?rNt~t{j41IiV9AXtUyPNd)aFLb4{5d~8qb0m&O26MKZ^$0| zsV8N2qiTE7Vwt_?niOAkM0|P@zxvhL=My-@-mTAz?ERzO+phR^$J2k>%2TW7JP_$B zd@&=9wfa+;&+h7Skxh(y;!AS_uJ7wV#^X0D#p&BdwtTbyA7)l1mF-83T(gKTwGV*a zYz2B(0^!}_jMUVUVtp`W2#Vj{2?z5IEATkHuVMXE+v?-BHKZjgYODSWz1@nBOfndi z&RqKX-g|TDUB1a}DJQ4NpJ&_p_k;3$!-?GsTeDOflQ(QEet6-~>3=`pFtY}qyo~g`olX36D&^ZQE{NyK_@0{{dA;T+U*V9SzNB2(2 ze%Q->;Ky5?O}@YS^}g-hyrcWu-Q*MV_q8lO`Bc?pN2i1FvT23ZlYMVm9#z(V#kFPA z^d}!Do!u3bbj|CAQ0tDuuT!g6iLd+Dyn4x$@GoCi&nUfYvcFQb?#-boKmIw^vITfE zGKnzbF8_c*0|pJiaKc&|q8or-9ze7+Ff=e40*yc_9ndwRXE%gKZeUrAJM*ENgPxEP r=KO@1gPg3(6r1^V(I3y22**58a? literal 0 HcmV?d00001 diff --git a/tests/fixtures/truncated_zip.xlsx b/tests/fixtures/truncated_zip.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..efc602e81de303a713d2ba9a8d0e4d70f542741d GIT binary patch literal 798 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 B@GbxV literal 0 HcmV?d00001 diff --git a/tests/generate_corpus.py b/tests/generate_corpus.py new file mode 100644 index 0000000..eaf506a --- /dev/null +++ b/tests/generate_corpus.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""Generate diverse XLSX test corpus for broader coverage and fuzz seeding. + +Creates files that test edge cases across different Excel features, +data types, and structural variations. Each file is a valid XLSX that +exercises a specific code path in the reader. +""" + +import os +import struct +import zipfile +from io import BytesIO +from pathlib import Path + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _minimal_xlsx(sheets_xml: dict[str, str], shared_strings: str | None = None, + workbook_xml: str | None = None, extra_files: dict[str, str] | None = None) -> bytes: + """Build a minimal XLSX from raw XML parts.""" + buf = BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + # [Content_Types].xml + overrides = "" + for name in sheets_xml: + idx = int(name.replace("sheet", "").replace(".xml", "")) + overrides += f'\n' + if shared_strings: + overrides += '\n' + zf.writestr("[Content_Types].xml", f""" + + + + +{overrides} +""") + + # _rels/.rels + zf.writestr("_rels/.rels", """ + + +""") + + # xl/_rels/workbook.xml.rels + rels = "" + for i, name in enumerate(sheets_xml, 1): + rels += f'\n' + if shared_strings: + rels += f'\n' + zf.writestr("xl/_rels/workbook.xml.rels", f""" + +{rels} +""") + + # xl/workbook.xml + if workbook_xml is None: + sheet_entries = "" + for i, name in enumerate(sheets_xml, 1): + sheet_entries += f'\n' + workbook_xml = f""" + +{sheet_entries} +""" + zf.writestr("xl/workbook.xml", workbook_xml) + + # Worksheets + for name, xml in sheets_xml.items(): + zf.writestr(f"xl/worksheets/{name}", xml) + + # Shared strings + if shared_strings: + zf.writestr("xl/sharedStrings.xml", shared_strings) + + # Extra files + if extra_files: + for path, content in extra_files.items(): + zf.writestr(path, content) + + return buf.getvalue() + + +def _sheet_xml(rows_xml: str, merges: str = "", extras: str = "") -> str: + """Wrap row XML in a full worksheet element.""" + merge_block = f"{merges}" if merges else "" + return f""" + +{extras} +{rows_xml} +{merge_block} +""" + + +def gen_empty_workbook(): + """Workbook with a single sheet containing no data.""" + return _minimal_xlsx({"sheet1.xml": _sheet_xml("")}) + + +def gen_single_cell(): + """Workbook with exactly one cell.""" + rows = '42' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}) + + +def gen_many_data_types(): + """Cells with every data type: number, bool, string, inline string, formula, empty.""" + ss = """ + +hello +world +""" + rows = """ + + 3.14 + 1 + 0 + 1 + inline + + + SUM(A1:A1)3.14 + #REF! + +""" + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, shared_strings=ss) + + +def gen_multiple_sheets(): + """Workbook with 5 sheets including hidden ones.""" + sheets = {} + for i in range(1, 6): + rows = f'{i}' + sheets[f"sheet{i}.xml"] = _sheet_xml(rows) + + sheet_entries = "" + for i in range(1, 6): + state = ' state="hidden"' if i == 3 else (' state="veryHidden"' if i == 5 else "") + sheet_entries += f'\n' + wb = f""" + +{sheet_entries} +""" + return _minimal_xlsx(sheets, workbook_xml=wb) + + +def gen_merged_cells(): + """Sheet with merged cell regions.""" + rows = """ +12 +3 +4""" + merges = '' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows, merges=merges)}) + + +def gen_sparse_rows(): + """Sheet with gaps in row numbers (rows 1, 5, 1000).""" + rows = """ +1 +5 +999""" + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}) + + +def gen_unicode_strings(): + """Shared strings with Unicode: emoji, CJK, RTL, combining chars.""" + strings = [ + "Hello 🌍🎉", + "日本語テスト", + "العربية", + "café résumé naïve", + "Z̤͔ͧ̑a̴̬l̶̞g̗̲ͧo̙̫", # Combining characters + "", # Empty string + " ", # Space only + "\t\n\r", # Whitespace + "a" * 32767, # Max Excel cell length + ] + si_entries = "".join(f"{s}" for s in strings) + ss = f""" + +{si_entries} +""" + cells = "".join(f'{i}' for i in range(len(strings))) + rows = f'{cells}' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, shared_strings=ss) + + +def gen_rich_text_strings(): + """Shared strings with rich text runs ( elements).""" + ss = """ + +Bold Normal +Red Italic +""" + rows = '01' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, shared_strings=ss) + + +def gen_freeze_panes(): + """Sheet with freeze panes set.""" + extras = '' + rows = '1' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows, extras=extras)}) + + +def gen_auto_filter(): + """Sheet with auto-filter on a range.""" + extras = "" + rows = """ +NameAge +Alice30""" + af = '' + sheet = f""" + +{rows} +{af} +""" + return _minimal_xlsx({"sheet1.xml": sheet}) + + +def gen_defined_names(): + """Workbook with named ranges / defined names.""" + sheets = {"sheet1.xml": _sheet_xml('1')} + wb = """ + + + +Sheet1!$A$1:$C$10 +Sheet1!$A$1:$Z$100 + + +""" + return _minimal_xlsx(sheets, workbook_xml=wb) + + +def gen_large_shared_strings(): + """Large shared string table (1000 entries).""" + entries = "".join(f"string_{i:04d}" for i in range(1000)) + ss = f""" + +{entries} +""" + cells = "".join(f'{i}' for i in range(100)) + rows = "" + for r in range(1, 5): + row_cells = "".join(f'{(r-1)*26+c}' for c in range(26)) + rows += f'{row_cells}' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, shared_strings=ss) + + +def gen_date_cells(): + """Cells with date serial numbers and a styles.xml with date formats.""" + styles = """ + + + + + + + + + + + +""" + rows = """ + + 44197 + 44197 + 1 + 44197 +""" + extra = {"xl/styles.xml": styles} + content_type_override = '' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, extra_files=extra) + + +def gen_column_widths(): + """Sheet with custom column widths.""" + extras = '' + rows = '1' + sheet = f""" + +{extras} +{rows} +""" + return _minimal_xlsx({"sheet1.xml": sheet}) + + +def gen_inline_strings(): + """Cells using inline string type (t="inlineStr") with ....""" + rows = """ + + Inline A + Inline B + Rich inline +""" + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}) + + +def gen_document_properties(): + """Workbook with core and custom document properties.""" + core = """ + +Test Workbook +Test Author +A test description +2024-01-15T10:30:00Z +""" + custom = """ + + +Engineering + +""" + extras = {"docProps/core.xml": core, "docProps/custom.xml": custom} + rows = '1' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, extra_files=extras) + + +# ---- Malformed / edge-case files for robustness testing ---- + +def gen_truncated_zip(): + """A valid ZIP header followed by truncated data.""" + valid = gen_single_cell() + return valid[:len(valid) // 2] # Cut in half + + +def gen_empty_zip(): + """A valid but empty ZIP file (no entries).""" + buf = BytesIO() + with zipfile.ZipFile(buf, "w"): + pass + return buf.getvalue() + + +def gen_missing_workbook(): + """ZIP with worksheet but no workbook.xml.""" + buf = BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("[Content_Types].xml", '') + zf.writestr("xl/worksheets/sheet1.xml", _sheet_xml('1')) + return buf.getvalue() + + +def gen_malformed_xml(): + """XLSX with syntactically invalid XML in the worksheet.""" + rows = '1>>"}) + + +def gen_wrong_string_index(): + """Shared string reference pointing beyond table size.""" + ss = """ + +only_one +""" + rows = '999' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}, shared_strings=ss) + + +def gen_negative_row_number(): + """Row with negative or zero row number.""" + rows = '0' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}) + + +def gen_huge_row_gap(): + """Rows at 1 and 1048576 (Excel max), testing sparse allocation.""" + rows = '12' + return _minimal_xlsx({"sheet1.xml": _sheet_xml(rows)}) + + +GENERATORS = { + # Valid files + "empty_workbook.xlsx": gen_empty_workbook, + "single_cell.xlsx": gen_single_cell, + "many_data_types.xlsx": gen_many_data_types, + "multiple_sheets.xlsx": gen_multiple_sheets, + "merged_cells.xlsx": gen_merged_cells, + "sparse_rows.xlsx": gen_sparse_rows, + "unicode_strings.xlsx": gen_unicode_strings, + "rich_text_strings.xlsx": gen_rich_text_strings, + "freeze_panes.xlsx": gen_freeze_panes, + "auto_filter.xlsx": gen_auto_filter, + "defined_names.xlsx": gen_defined_names, + "large_shared_strings.xlsx": gen_large_shared_strings, + "date_cells.xlsx": gen_date_cells, + "column_widths.xlsx": gen_column_widths, + "inline_strings.xlsx": gen_inline_strings, + "document_properties.xlsx": gen_document_properties, + # Malformed / edge cases + "truncated_zip.xlsx": gen_truncated_zip, + "empty_zip.xlsx": gen_empty_zip, + "missing_workbook.xlsx": gen_missing_workbook, + "malformed_xml.xlsx": gen_malformed_xml, + "wrong_string_index.xlsx": gen_wrong_string_index, + "negative_row_number.xlsx": gen_negative_row_number, + "huge_row_gap.xlsx": gen_huge_row_gap, +} + + +def main(): + FIXTURES.mkdir(parents=True, exist_ok=True) + for name, gen in GENERATORS.items(): + path = FIXTURES / name + data = gen() + path.write_bytes(data) + print(f" {name} ({len(data):,} bytes)") + print(f"\nGenerated {len(GENERATORS)} files in {FIXTURES}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_corpus.py b/tests/test_corpus.py new file mode 100644 index 0000000..ac481cb --- /dev/null +++ b/tests/test_corpus.py @@ -0,0 +1,236 @@ +"""Test the reader against a diverse corpus of XLSX files. + +Covers valid files (different features, data types, structural variations) +and malformed files (truncated, missing parts, invalid XML) to ensure +the reader either succeeds or returns a clean error — never panics. +""" + +import os +import pytest +from pathlib import Path + +import opensheet_core + +FIXTURES = Path(__file__).parent / "fixtures" + + +# ---- Helpers ---- + +def read_or_error(path: str): + """Try reading; return result or exception (never raises).""" + try: + return opensheet_core.read_xlsx(path) + except Exception as e: + return e + + +# ---- Valid corpus: must parse successfully ---- + +class TestValidCorpus: + """Files that are valid XLSX and must parse without error.""" + + def test_empty_workbook(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "empty_workbook.xlsx")) + assert len(sheets) == 1 + assert sheets[0]["rows"] == [] + + def test_single_cell(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "single_cell.xlsx")) + rows = sheets[0]["rows"] + assert len(rows) == 1 + assert rows[0][0] == 42.0 + + def test_many_data_types(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "many_data_types.xlsx")) + sheet = sheets[0] + row1 = sheet["rows"][0] + # Number + assert row1[0] == pytest.approx(3.14) + # Bool + assert row1[1] is True + # Shared strings + assert row1[2] == "hello" + assert row1[3] == "world" + + def test_multiple_sheets(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "multiple_sheets.xlsx")) + assert len(sheets) == 5 + # Check sheet states + states = [s["state"] for s in sheets] + assert states[0] == "visible" + assert states[2] == "hidden" + assert states[4] == "veryHidden" + + def test_merged_cells(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "merged_cells.xlsx")) + sheet = sheets[0] + assert len(sheet["merges"]) == 2 + assert "A1:B2" in sheet["merges"] + + def test_sparse_rows(self): + """Sparse rows should produce a grid with gaps filled by None.""" + sheets = opensheet_core.read_xlsx(str(FIXTURES / "sparse_rows.xlsx")) + rows = sheets[0]["rows"] + assert len(rows) >= 1000 # Should have rows up to 1000 + + def test_unicode_strings(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "unicode_strings.xlsx")) + row = sheets[0]["rows"][0] + assert "🌍" in row[0] # Emoji + assert "日本語" in row[1] # CJK + + def test_rich_text_strings(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "rich_text_strings.xlsx")) + row = sheets[0]["rows"][0] + assert "Bold" in row[0] + assert "Normal" in row[0] + + def test_freeze_panes(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "freeze_panes.xlsx")) + sheet = sheets[0] + assert sheet["freeze_pane"] is not None + + def test_auto_filter(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "auto_filter.xlsx")) + sheet = sheets[0] + assert sheet["auto_filter"] == "A1:B2" + + def test_defined_names(self): + names = opensheet_core.defined_names(str(FIXTURES / "defined_names.xlsx")) + assert any(n["name"] == "MyRange" for n in names) + + def test_large_shared_strings(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "large_shared_strings.xlsx")) + sheet = sheets[0] + assert len(sheet["rows"]) > 0 + + def test_date_cells(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "date_cells.xlsx")) + rows = sheets[0]["rows"] + assert len(rows) > 0 + + def test_column_widths(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "column_widths.xlsx")) + widths = sheets[0]["column_widths"] + assert len(widths) > 0 + + def test_inline_strings(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "inline_strings.xlsx")) + row = sheets[0]["rows"][0] + assert row[0] == "Inline A" + assert row[1] == "Inline B" + + def test_document_properties(self): + sheets = opensheet_core.read_xlsx(str(FIXTURES / "document_properties.xlsx")) + assert len(sheets) > 0 + + def test_sheet_names(self): + """sheet_names() should work on all valid files.""" + for name in ["multiple_sheets.xlsx", "defined_names.xlsx", "empty_workbook.xlsx"]: + names = opensheet_core.sheet_names(str(FIXTURES / name)) + assert isinstance(names, list) + assert len(names) > 0 + + def test_read_sheet_by_index(self): + rows = opensheet_core.read_sheet(str(FIXTURES / "multiple_sheets.xlsx"), sheet_index=1) + assert isinstance(rows, list) + + def test_read_sheet_by_name(self): + rows = opensheet_core.read_sheet(str(FIXTURES / "multiple_sheets.xlsx"), sheet_name="Sheet3") + assert isinstance(rows, list) + + +# ---- Malformed corpus: must not panic ---- + +class TestMalformedCorpus: + """Files that are invalid or malformed. The reader must return + a clean error (exception), never panic or hang.""" + + def test_truncated_zip(self): + r = read_or_error(str(FIXTURES / "truncated_zip.xlsx")) + assert isinstance(r, Exception) + + def test_empty_zip(self): + r = read_or_error(str(FIXTURES / "empty_zip.xlsx")) + assert isinstance(r, Exception) + + def test_missing_workbook(self): + r = read_or_error(str(FIXTURES / "missing_workbook.xlsx")) + assert isinstance(r, Exception) + + def test_malformed_xml(self): + """Malformed XML should produce an error or degrade gracefully.""" + r = read_or_error(str(FIXTURES / "malformed_xml.xlsx")) + # May succeed partially or error — just must not panic + assert r is not None + + def test_wrong_string_index(self): + """Out-of-bounds shared string index should not panic.""" + r = read_or_error(str(FIXTURES / "wrong_string_index.xlsx")) + assert r is not None + + def test_negative_row_number(self): + """Row number 0 should not cause a panic.""" + r = read_or_error(str(FIXTURES / "negative_row_number.xlsx")) + assert r is not None + + def test_huge_row_gap(self): + """Rows at 1 and 1048576 should not OOM or panic.""" + r = read_or_error(str(FIXTURES / "huge_row_gap.xlsx")) + assert r is not None + + def test_random_bytes(self): + """Completely random data should produce a clean error.""" + import tempfile + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f: + f.write(os.urandom(1024)) + f.flush() + r = read_or_error(f.name) + assert isinstance(r, Exception) + os.unlink(f.name) + + def test_nonexistent_file(self): + r = read_or_error("/nonexistent/path/file.xlsx") + assert isinstance(r, Exception) + + +# ---- Roundtrip: write then read ---- + +class TestRoundtrip: + """Write diverse data and verify we can read it back.""" + + def test_roundtrip_all_types(self): + import tempfile + path = tempfile.mktemp(suffix=".xlsx") + try: + w = opensheet_core.XlsxWriter(path) + w.add_sheet("Types") + w.write_row([1, 2.5, True, False, "text", None, ""]) + w.write_row([0, -1, 1e10, 1e-10, "🎉", " ", "\t"]) + w.close() + + sheets = opensheet_core.read_xlsx(path) + rows = sheets[0]["rows"] + assert rows[0][0] == 1.0 + assert rows[0][2] is True + assert rows[0][4] == "text" + assert rows[1][4] == "🎉" + finally: + os.unlink(path) + + def test_roundtrip_many_sheets(self): + import tempfile + path = tempfile.mktemp(suffix=".xlsx") + try: + w = opensheet_core.XlsxWriter(path) + for i in range(10): + w.add_sheet(f"Sheet{i}") + w.write_row([i, f"data_{i}"]) + w.close() + + names = opensheet_core.sheet_names(path) + assert len(names) == 10 + assert names[0] == "Sheet0" + assert names[9] == "Sheet9" + finally: + os.unlink(path)