Skip to content

Commit 7181b55

Browse files
committed
Harden XLSX inserts with bounded streaming and conflict detection
1 parent 2463ce0 commit 7181b55

20 files changed

Lines changed: 998 additions & 119 deletions

‎Cargo.lock‎

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ calamine = { version = "0.35.0", features = ["chrono"] }
1616
chrono = { version = "0.4.42", features = ["serde"] }
1717
clap = { version = "4.6.6", features = ["derive"] }
1818
console_error_panic_hook = "0.1.7"
19+
fs2 = "0.4.3"
1920
indexmap = { version = "2.12.1", features = ["serde"] }
2021
quick-xml = "0.39.4"
2122
rust_xlsxwriter = { version = "0.96.0", features = ["chrono", "serde"] }

‎README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ let count = MiniExcel::insert(
294294
assert_eq!(count, 1);
295295
```
296296

297-
`insert_with_schema()` accepts a fallible, one-pass dynamic iterator. Source rows are disk-spooled and the constant-memory backend retains only the current row while generating the donor workbook; style rebasing currently materializes the generated worksheet XML. `insert_serialized()` accepts Serde structs. Existing unrelated ZIP entries, worksheet identities, formulas, and cached values are preserved, and an existing workbook is replaced only after the rewritten package validates and syncs.
297+
`insert_with_schema()` accepts a fallible, one-pass dynamic iterator. Source rows and generated donor worksheet XML are disk-spooled; row generation, shared-string conversion, style-ID rebasing, and ZIP output are streamed without retaining the complete worksheet XML. `insert_serialized()` accepts Serde structs. Existing unrelated ZIP entries, worksheet identities, formulas, and cached values are preserved, and an existing workbook is replaced only after the rewritten package validates and syncs.
298298

299299
For separate borrowed streams, use `insert_from_reader_to_writer()`,
300300
`insert_with_schema_from_reader_to_writer()`, or
@@ -307,6 +307,13 @@ or post-write validation guarantee.
307307

308308
The default `ExistingSheetPolicy::Reject` rejects duplicate worksheet names case-insensitively. Use `ExistingSheetPolicy::Replace` to replace a worksheet in place while preserving its workbook order, ID, relationship/path, visibility, and active state. The default `TargetRelationshipPolicy::Reject` accepts only a plain target with no worksheet relationships. `RemoveSupported` can remove target-owned tables, drawings with exclusively owned images, comments, VML drawings, and external hyperlinks; pivots, external links, unknown relationships, and shared/global parts are rejected or preserved conservatively. Insert writes XLSX packages, rejects macro-enabled `.xlsm` paths, and rejects `WriteOptions::with_overwrite_file(true)` because workbook replacement is controlled by the insert policy.
309309

310+
Insert preflight limits package entry count, individual and aggregate control XML, XML attribute
311+
size, XML depth, and relationship count. It rejects unsafe or aliased part paths, internal
312+
relationship cycles, duplicate semantic relationship targets, and Strict OOXML packages. Path
313+
Insert holds a cross-process advisory lock and verifies a source SHA-256 fingerprint before commit;
314+
concurrent writers or external source changes return a deterministic conflict instead of silently
315+
overwriting newer content.
316+
310317
Appending a formula-free worksheet preserves an existing calculation chain and workbook calculation properties. Replacement removes the complete stale `calcChain` part, relationship, and content-type override, then sets `fullCalcOnLoad` and `forceFullCalc` so Excel recalculates on the next open. MiniExcel does not evaluate or rewrite formulas; formulas and cached values in untouched worksheets remain byte-identical.
311318

312319
## Typed Writing

‎README.zh-CN.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ let count = MiniExcel::insert(
294294
assert_eq!(count, 1);
295295
```
296296

297-
`insert_with_schema()` 接受可返回错误、只消费一次的动态 iterator。源 row 会先落盘 spool,constant-memory backend 在生成 donor workbook 时只保留当前 row;style rebase 当前仍会物化生成的 worksheet XML。`insert_serialized()` 接受 Serde struct。现有无关 ZIP entry、worksheet identity、formula 和 cached value 均会保留;只有重写 package 完成验证并同步后,才原子替换现有 workbook。
297+
`insert_with_schema()` 接受可返回错误、只消费一次的动态 iterator。源 row 与生成的 donor worksheet XML 都会落盘 spool;row generation、shared-string conversion、style-ID rebase 和 ZIP output 均为流式处理,不会保留完整 worksheet XML。`insert_serialized()` 接受 Serde struct。现有无关 ZIP entry、worksheet identity、formula 和 cached value 均会保留;只有重写 package 完成验证并同步后,才原子替换现有 workbook。
298298

299299
对于两个独立的 borrowed stream,可使用 `insert_from_reader_to_writer()`、
300300
`insert_with_schema_from_reader_to_writer()` 或
@@ -306,6 +306,12 @@ truncate,也不会在错误后 rollback,因此 destination 写入失败可
306306

307307
默认的 `ExistingSheetPolicy::Reject` 会不区分大小写地拒绝重复 worksheet name。使用 `ExistingSheetPolicy::Replace` 可原位替换 worksheet,并保留其 workbook 顺序、ID、relationship/path、visibility 与 active state。默认 `TargetRelationshipPolicy::Reject` 只接受没有 worksheet relationship 的 plain target。`RemoveSupported` 可删除 target-owned table、drawing 及其独占 image、comment、VML drawing 和 external hyperlink;pivot、external link、未知 relationship 与 shared/global part 会被拒绝或保守保留。Insert 写入 XLSX package,拒绝 macro-enabled `.xlsm` path,并拒绝 `WriteOptions::with_overwrite_file(true)`,因为 workbook replacement 由 Insert policy 控制。
308308

309+
Insert preflight 会限制 package entry count、单个和累计 control XML、XML attribute size、
310+
XML depth 与 relationship count;同时拒绝不安全或别名 part path、内部 relationship cycle、
311+
重复语义 relationship target 与 Strict OOXML package。Path Insert 会持有跨进程 advisory
312+
lock,并在 commit 前校验 source SHA-256 fingerprint;并发 writer 或外部 source 变化会返回
313+
确定性 conflict,不会静默覆盖更新后的内容。
314+
309315
追加 formula-free worksheet 时会保留已有 calculation chain 与 workbook calculation property。Replacement 会完整删除 stale `calcChain` part、relationship 和 content-type override,并设置 `fullCalcOnLoad` 与 `forceFullCalc`,让 Excel 下次打开时重算。MiniExcel 不执行或改写公式;未修改 worksheet 中的 formula 与 cached value 保持原始字节。
310316

311317
## 类型化写入

‎docs/compatibility.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The Rust MVP implements the smallest useful MiniExcel-style XLSX read/write surf
1111
| Dependency | Locked API line | Role | License | MSRV note |
1212
| --- | --- | --- | --- | --- |
1313
| `atomicwrites` | 0.4 | Safe cross-platform atomic replacement for Windows path inserts | MIT | Windows-only dependency; checked with Rust 1.85 |
14+
| `fs2` | 0.4 | Cross-platform advisory locking for path inserts | MIT OR Apache-2.0 | Checked with Rust 1.85 |
1415
| `calamine` | 0.35 | XLSX parsing and Serde row deserialization | MIT | 0.35 declares Rust 1.83 |
1516
| `clap` | 4.6 | Local CLI argument parsing | MIT OR Apache-2.0 | 4.6 declares Rust 1.85 |
1617
| `rust_xlsxwriter` | 0.96 | New XLSX workbook generation and Serde serialization | MIT OR Apache-2.0 | 0.96 declares Rust 1.83 |
@@ -114,7 +115,7 @@ Path RAG exports retain parser state, repeated header context, and one output ch
114115

115116
The backend makes two sequential, bounded-memory passes over the selected worksheet entry. The first records the used extent and compact merged-cell rectangles. This is required for MiniExcel-compatible stable dynamic schemas when legal files omit `<dimension>`, to preserve style-only row elements like the .NET reader, and to support opt-in merged-cell filling without expanding ranges into an address map. The second pass emits rows and retains only anchor values for currently active merged ranges. Worksheet XML and prior rows are never retained; memory consists primarily of in-memory or disk-indexed shared strings, styles, merge metadata, parser buffers, the current row, and the bounded channel.
116117

117-
The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer, while the generated donor worksheet XML is materialized for style rebasing. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows.
118+
The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows.
118119

119120
## Test Sources
120121

‎docs/compatibility.zh-CN.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
1111
| 依赖 | 锁定 API 版本线 | 用途 | 许可证 | MSRV 说明 |
1212
| --- | --- | --- | --- | --- |
1313
| `atomicwrites` | 0.4 | Windows path insert 的安全跨平台原子替换 | MIT | 仅 Windows 依赖;已使用 Rust 1.85 检查 |
14+
| `fs2` | 0.4 | Path insert 的跨平台 advisory lock | MIT OR Apache-2.0 | 已使用 Rust 1.85 检查 |
1415
| `calamine` | 0.35 | XLSX 解析和 Serde row 反序列化 | MIT | 0.35 声明 Rust 1.83 |
1516
| `clap` | 4.6 | 本地 CLI 参数解析 | MIT OR Apache-2.0 | 4.6 声明 Rust 1.85 |
1617
| `rust_xlsxwriter` | 0.96 | 新 XLSX workbook 生成和 Serde 序列化 | MIT OR Apache-2.0 | 0.96 声明 Rust 1.83 |
@@ -114,7 +115,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
114115

115116
backend 对所选 worksheet entry 执行两次顺序、有界内存扫描。第一次记录使用范围和紧凑 merged-cell 矩形。这是为了在合法文件省略 `<dimension>` 时保持 MiniExcel 兼容的稳定动态 schema、像 .NET reader 一样保留仅含 style 的 row element,并在不展开地址 map 的情况下支持按需 merged-cell 填充。第二次扫描输出 row,只保留当前活动 merge range 的锚点值。Worksheet XML 和先前 row 永远不会保留;内存主要由内存或磁盘索引的 shared string、style、merge metadata、parser buffer、当前 row 和有界 channel 构成。
116117

117-
内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理;生成的 donor worksheet XML 会为 style rebase 而物化。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。
118+
内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理。生成的 donor worksheet XML、shared-string conversion、style-ID rebase 与 ZIP insertion 均使用临时文件 stream,因此 worksheet memory 与 row count 无关。Path Insert 还通过 advisory lock 与 commit 前 source fingerprint 防止并发更新丢失。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。
118119

119120
## 测试来源
120121

‎docs/dotnet-feature-gaps.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ Rust already implements dynamic and Serde-typed XLSX path queries, inclusive A1
2929
| --- | --- | --- |
3030
| Named tables | Missing | Query an OpenXML table by table name (`QueryTable`), including table-specific headers and bounds. |
3131
| DataReader and DataTable | Missing | `IDataReader`/async reader access, schema tables, typed getters, `NextResult` sheet traversal, and `DataTable` materialization. |
32-
| Caller-owned streams | Partial | Borrowed synchronous dynamic/typed/structured visitors, metadata reads, and dynamic/schema/typed/multi-sheet writers are implemented with leave-open semantics. Borrowed lazy iterators, async streams, and template streams remain unsupported. |
32+
| Caller-owned streams | Partial | Borrowed synchronous dynamic/typed/structured visitors, metadata reads, dynamic/schema/typed/multi-sheet writers, and separate reader-to-writer Insert are implemented with leave-open semantics. Borrowed lazy iterators, async streams, and template streams remain unsupported. |
3333
| Async and cancellation | Missing | Async query/export/template operations, async row sources, cancellation tokens, and progress callbacks. Rust iterators are synchronous even though path reads use a worker thread. |
3434
| General save inputs | Partial | Export from general objects/enumerables, dictionaries, `DataTable`, `IDataReader`, and async enumerables, with progress. Rust accepts dynamic or same-type Serde slices and reports per-sheet row counts. |
3535
| Multi-sheet export | Partial | Rust creates ordered visible, hidden, and very-hidden worksheets, but does not yet accept heterogeneous Serde row types in one call. |
36-
| Existing-workbook operations | Missing | Insert or replace a sheet, copy and add a sheet, and rename, reorder, or change visibility of sheets. Rust always creates a new XLSX package. |
36+
| Existing-workbook operations | Partial | Rust appends or strictly replaces worksheets through atomic path APIs or separate borrowed streams, preserving unrelated package parts and worksheet identity. Copy-and-add, rename, reorder, and standalone visibility mutation remain unsupported. |
3737
| Templates | Partial | Rust fills path/byte templates with scalar values and single-row arrays while preserving package parts. Streams, grouping, conditions, parametrized sheets, `$=` formulas, formula-reference updates, and calculation-chain handling remain unsupported. |
3838
| Pictures and merge processing | Missing | Add anchored pictures and merge adjacent identical cells through the templater surface. Structured reads do not provide authoring parity. |
3939
| CSV | Missing | Dynamic/typed CSV query and save, append, columns, DataReader/DataTable, delimiter/newline/encoding/quoting configuration, and CSV/XLSX conversion. |
@@ -52,7 +52,7 @@ Rust already implements dynamic and Serde-typed XLSX path queries, inclusive A1
5252
| Public read/write boundary | `miniexcel/src/facade.rs`, `miniexcel/src/options.rs` | `src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs`, `OpenXmlExporter.cs` |
5353
| Tables | No public table API | `OpenXmlImporter.QueryTableAsync`; `tests/MiniExcel.OpenXml.Tests/Tables/` |
5454
| DataReader/DataTable | No public tabular adapter | `OpenXmlImporter.GetDataReader`, `GetAsyncDataReader`, `QueryAsDataTableAsync`; `tests/MiniExcel.OpenXml.Tests/DataReader/` |
55-
| Multi-sheet and workbook edits | Writer creates multiple sheets in a new workbook; existing-workbook edits remain unsupported | `OpenXmlExporter.InsertSheetAsync`, `CopyAndAddSheetAsync`, `AlterSheetAsync`; `tests/MiniExcel.OpenXml.Tests/MultipleSheets/` and `AlterSheets/` |
55+
| Multi-sheet and workbook edits | Writer creates multiple sheets; existing workbooks support append and strict replacement with package preservation and bounded explicit-schema producers | `OpenXmlExporter.InsertSheetAsync`, `CopyAndAddSheetAsync`, `AlterSheetAsync`; `tests/MiniExcel.OpenXml.Tests/MultipleSheets/` and `AlterSheets/` |
5656
| Templates/pictures/merges | Basic template fill implemented; advanced directives and authoring remain deferred | `src/MiniExcel.OpenXml/Api/OpenXmlTemplater.cs`; `tests/MiniExcel.OpenXml.Tests/Templates/` |
5757
| CSV/conversion | XLSX-only core | `src/MiniExcel.Csv/Api/`; `src/MiniExcel/MiniExcelConverter.cs`; `tests/MiniExcel.Csv.Tests/` |
5858
| Mapping | Serde mapping only | `src/MiniExcel.Core/Attributes/MiniExcelColumnAttribute.cs`; `src/MiniExcel.OpenXml.FluentMapping/`; mapping tests |

0 commit comments

Comments
 (0)