Adds the XLSX fuzzers to make fuzz.
- commit
- 71a503a2343bafd59f62968aaa11e2991f128e0a
- parent
- e5c66aafc38a45449b9e3d5fe71e351514a4793f
- author
- Dami <dami.etoile@gmail.com>
| M | Makefile | 2 | + |
| M | docs/ | 21 | ++++++++++----- |
| M | docs/ | 2 | +- |
| M | docs/ | 44 | ++++++++++++++++++++++++++---- |
| M | docs/ | 16 | ++++++++++- |
@@ -27,6 +27,8 @@ fuzz:
2727 go test ./internal/sheet -run '^$$' -fuzz FuzzParse -fuzztime 60s2828 go test ./internal/fileio -run '^$$' -fuzz FuzzReadDelimited -fuzztime 60s2929 go test ./internal/fileio -run '^$$' -fuzz FuzzReadWK1 -fuzztime 60s30+ go test ./internal/fileio -run '^$$' -fuzz 'FuzzReadXLSX$$' -fuzztime 60s31+ go test ./internal/fileio -run '^$$' -fuzz FuzzReadXLSXParts -fuzztime 60s3032 3133 e2e: $(GHOSTTY_STAMP)3234 cd e2e && PKG_CONFIG_PATH=$(GHOSTTY_OUT)/share/pkgconfig go test -count=1 ./...@@ -23,7 +23,7 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
2323 rowtext laying out a row of cell text across the columns on screen2424 formula reading the formula being typed (F4, the word and call at the caret)2525 e2e/ end-to-end tests through libghostty (separate module, cgo)26-oracle/ differential tests against excelize (separate module)26+oracle/ differential tests against excelize's calculation (separate module)2727 ```2828 2929 ## The engine@@ -205,12 +205,19 @@ falls back to a formula's cached value when it can't be translated, and
205205 counts what didn't fit. They stream: CSV and TSV are read record by206206 record, Parquet a batch of rows at a time, SQLite a row at a time, and207207 Parquet files and SQLite tables stop at the sheet's last row, taking the208-number of rows left out from the file. XLSX goes through excelize, which209-holds the worksheet in memory while its rows are read, so it is the one210-format read twice; its files are small next to the others' and it's211-bounded by the sheet's size. Exporters write a `Snapshot` taken on the UI212-goroutine, in the background, through one atomic write-then-rename; CSV213-and TSV are written a row at a time. One package suits formats that share208+number of rows left out from the file. XLSX is read by 012's own209+SpreadsheetML reader on `archive/zip` and `encoding/xml`: the workbook,210+shared strings (kept end to end in one buffer) and styles first, then211+each worksheet a token at a time, a row at a time, expanding shared212+formulas only for the cells kept. The file is untrusted, so the reader213+caps what it can be made to do (`xlsxLimits`: uncompressed bytes per214+part and in all, compression ratio, zip entries, XML nesting and token215+size, shared strings, styles, sheets, and the text shared formulas216+expand to) and refuses unsafe part names and references past Excel's217+edges; `FuzzReadXLSX` and `FuzzReadXLSXParts` fuzz it. XLSX is written218+with the same packages, a worksheet part streamed per sheet. Exporters219+write a `Snapshot` taken on the UI goroutine, in the background, through220+one atomic write-then-rename; CSV and TSV are written a row at a time. One package suits formats that share214221 this much (the builder, number formats, serial dates and Excel formula215222 translation); a format that grew its own dependencies would move to a216223 subpackage behind the same table row.@@ -10,7 +10,7 @@ bar; Esc cancels. Saving an imported sheet asks whether to save it as a
1010 | Format | Import | Download |1111 |---|---|---|1212 | CSV, TSV | Delimiter (`,` `;` tab `\|`), UTF-8 BOM, UTF-16 and Windows-1252 detected; entries become numbers, dates, currency and percentages as if typed; formulas stay text | Values as shown, as Sheets' Download does |13-| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values, and so do pivot tables: Excel gets the results, not a pivot |13+| Excel `.xlsx` | Every sheet, opening on the one Excel showed: values, formulas (references between sheets too), workbook named ranges, number formats, bold, italic, underline, strikethrough, alignment, column widths, shared formulas, dates in the 1904 system. Formulas 012 can't read (unknown functions) keep their values; Excel pivot tables come in as the values they showed; hidden sheets show, with a note. Files that unpack past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) are refused with a message saying which | Every sheet, the same, with formulas in Excel's syntax and their results cached. JEV functions and `#AND#` save as values, and so do pivot tables: Excel gets the results, not a pivot |1414 | SQLite | Pick a table or view, or type a query; a header row names the columns | The sheet shown or the selection as a table, first row as column names; a table of that name is replaced |1515 | Parquet | Every column, with dates and timestamps; lists joined with commas | |1616 | Lotus 1-2-3 `.wk1`, `.wks` | Numbers, labels with their alignment, formats, column widths, formulas translated (references, operators, `@SUM`, `@AVG`, `@IF`, `@ROUND`, `@PMT` and 60 more) or kept as values | |@@ -24,7 +24,7 @@ it lags, and past a second it stalls.
2424 | Full recalc | Any sheet without heavy range fan-in: under 40 ms for 213 k numbers | 1000 full-column SUMs: 170 ms | 8192 running totals: 0.7 s | Same as above |2525 | Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |2626 | Selection statistics | Any selection, once computed (cached) | Extending a selection over 2 M cells: 30 ms per key | | Summing 2 M map entries per change |27-| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s) | XLSX with formulas: 200 k cells/s | Data past the limits (dropped, with a note) | Building cells one at a time; XLSX formula translation |27+| Imports | CSV, SQLite, Parquet: 1 to 3 M cells/s (a full 2.1 M-cell sheet in about 1 s); XLSX numbers or text: 0.7 to 1.1 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past the limits (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |2828 | Undo | One step of any size: undo costs what the edit cost | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 250 MB | | Whole-cell before-images, about 300 B per cell per step |2929 | JEV | Up to about 1000 JEV cells: 0.3 ms of CPU per answer | 4000 JEV cells: 1.3 ms per answer, 5 s of CPU to answer them all | | Every answer recalculates every JEV cell (they're volatile) |3030 | Formula depth | 10,000 nested parentheses or IFs: under 5 ms | | No explicit limit; recursion grows the stack | Recursive parser and evaluator |@@ -44,9 +44,14 @@ Lotus 1-2-3 Release 2.
4444 | Live heap | 64 MB | 602 MB (about 300 B per number cell) |4545 | Save `.012` (JSON, one line per cell) | 74 ms, 4.6 MB | 1.04 s, 47 MB |4646 | Open `.012` | 134 ms (116 MB allocated) | 2.08 s (1.3 GB allocated) |47-| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 87 / 40 ms | 0.71 / 0.66 / 0.91 / 0.72 s |47+| Export CSV / TSV / XLSX / SQLite | 41 / 55 / 62 / 40 ms | 0.71 / 0.66 / 0.81 / 0.72 s |4848 | Frame at 200 x 60, whole | 1.06 ms | 1.05 ms |4949 50+XLSX exports are written by 012 with archive/zip since excelize went:51+measured back to back with the excelize writer, 8192 x 26 went from 33552+to 62 ms and 379 to 1.9 MB allocated, 8192 x 256 from 0.97 to 0.81 s53+(mostly compression) and 259 to 17 MB allocated.54+5055 Heap per non-blank cell after loading (`BenchmarkMemory`,5156 `BenchmarkImport`): numbers 301 B, formulas 707 B (the parsed tree and5257 reference lists), 200-character text 790 B, imported CSV 275 to 300 B.@@ -120,7 +125,7 @@ Real, openly licensed datasets fetched by `scripts/stress-data.sh`
120125 | Airport codes (CSV) | 86,134 x 13, first 8192 rows kept | 83 ms | 107 MB/s | 274 B |121126 | NOAA daily CO2 (CSV) | 18,304 x 2 | 6 ms | 2.7 M cells/s | 294 B |122127 | Country codes (CSV, Arabic, CJK) | 249 x 56 | 5.6 ms | 2.2 M cells/s | 272 B |123-| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas | 19 ms | 205 k cells/s | 358 B |128+| Apache POI formula tests (XLSX) | 788 rows, 1189 formulas, 3828 cells | 9.7 ms | 395 k cells/s | 425 B |124129 | Chinook PlaylistTrack (SQLite) | 8715 x 2 | 5.5 ms | 3.0 M cells/s | 293 B |125130 | Parquet alltypes_tiny_pages | 7300 x 13 | 40 ms | 2.4 M cells/s | 276 B |126131 @@ -128,9 +133,36 @@ Past the limits, imports keep the first 8192 rows and 256 columns and
128133 say how much they left out. Parquet files and SQLite tables stop reading129134 at the last row and take the count of the rest from the file; CSV, TSV130135 and SQLite queries are read to their end to count it, without keeping131-it (the airport codes file is read to its end). Every importer but XLSX132-streams, so memory follows the sheet, not the file; excelize holds an133-XLSX worksheet in memory while its rows are read.136+it (the airport codes file is read to its end). Every importer streams,137+so memory follows the sheet, not the file. XLSX used to go through138+excelize, which held each worksheet in memory while its rows were read;139+012's own reader streams it a token at a time, keeping only the shared140+strings (in one buffer, 4 bytes a string besides the text), the cell141+formats, and the first cell of each shared formula.142+143+XLSX imports of full sheets written by 012's exporter144+(`BenchmarkImportXLSX`) and the POI file, before (excelize) and after145+(012's reader), on an Apple M5 Pro. Peak heap is sampled while146+importing, per cell kept, and includes garbage not yet collected:147+148+| Workbook | Time | Allocated | Allocations | Heap per cell after | Peak heap per cell |149+|---|---|---|---|---|---|150+| 8192 x 26 numbers (213 k cells) | 480 -> 199 ms | 491 -> 170 MB | 8.4 -> 3.3 M | 304 -> 290 B | 1365 -> 589 B |151+| 8192 x 26 table, text and numbers | 642 -> 296 ms | 644 -> 234 MB | 14.4 -> 7.4 M | 297 -> 297 B | 1432 -> 722 B |152+| 8192 formulas (`=A1+1` down a column) | 33 -> 13.9 ms | 38 -> 17 MB | 718 -> 321 k | 752 -> 746 B | 4790 -> 2188 B |153+| Apache POI formula tests | 21.1 -> 9.7 ms | 22 -> 9.7 MB | 373 -> 179 k | 429 -> 425 B | 2343 -> 1288 B |154+155+The reader refuses files past `xlsxLimits` (in `internal/fileio/xlsxpkg.go`),156+with a message naming the limit: 10,000 files in the zip; 1 GB157+uncompressed in one part and 2 GB in all, counted as the bytes come out158+whatever the zip's headers say; a part compressed more than 250 to 1159+once past 16 MB (a zip bomb); XML nested more than 256 deep or a single160+tag or text over 32 MB; 16.7 M shared strings; 65,536 cell formats,161+fonts, number formats or names; 4096 sheets; 256 MB of text from162+expanding shared formulas; rows past 1,048,576, columns past XFD and163+rows of more than 16,384 cells; part names that are absolute, climb with164+`..` or hold a backslash. encoding/xml expands no external or declared165+entities, so entity bombs fail as unknown entities.134166 135167 ## Undo136168 @@ -3,7 +3,7 @@
33 ```sh44 make lint # go vet, cognitive complexity at most 25, Go files at most 500 lines55 make test # engine, file formats and UI unit tests6-make fuzz # fuzz the formula parser and the CSV and .wk1 readers6+make fuzz # fuzz the formula parser and the CSV, .wk1 and XLSX readers77 make oracle # compare formulas and number formats with excelize88 make e2e # run the real binary in a terminal emulator (needs Zig and pkg-config)99 make screens # rewrite the golden screens and build the review gallery@@ -63,6 +63,20 @@ formatted values with 012's. Known differences between Sheets and Excel,
6363 and places where excelize departs from Excel, are listed as skips with a6464 reason.6565 66+## The XLSX differential test67+68+`TestXLSXDifferential` (`internal/fileio/xlsx_diff_test.go`) imports a69+corpus of workbooks with 012's own XLSX reader and with the excelize70+importer it replaced (kept in `xlsx_excelize_test.go`), and compares71+every sheet as saved, the widths, names, sheet shown, rows and notes.72+The corpus is workbooks written by the test (hand-written SpreadsheetML,73+excelize and 012's exporter), the stress datasets' XLSX files when74+fetched (`STRESS_DIR`), and any directories in `XLSX_CORPUS`, such as75+excelize's own `test/` files in the module cache. Where excelize reads a76+file wrongly (formulas spread across merged cells, spaces dropped from77+shared formulas) the comparison leaves the difference out, with the78+reason next to it.79+6680 ## Live checks6781 6882 `JEV_LIVE_TEST=1 go test ./internal/jev -run TestLive` asks the real JEV