diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef1ff5e8..c91caf07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,17 +124,11 @@ jobs: - name: Resolve dependencies run: moon update - # Format check across the whole workspace. The 2026-07-10 nightly's - # moonfmt migrates moon.pkg `options("is-main": true)` to a new - # `pkgtype(kind: "executable")` form that the same nightly's default - # parser still REJECTS (the new syntax is behind preview feature - # flags), so a repo carrying either form cannot satisfy both halves. - # Until the parser ships by default, restore moon.pkg after fmt and - # gate formatting on source files only. + # Format check across the whole workspace, including executable package + # manifests now that every member uses the supported `pkgtype` syntax. - name: Format check run: | moon fmt - git checkout -- ':(glob)**/moon.pkg' if ! git diff --quiet; then echo "::error::moon fmt changed files; run moon fmt and commit the result." git --no-pager diff @@ -279,9 +273,9 @@ jobs: and .success == true and .data.schema == "office.capabilities/2" and (.data.fingerprint | test("^crc32:[0-9a-f]{8}$")) - and ([.data.records[].name] == ["docx", "xlsx", "help", "identify", "outline", "get", "text", "query", "raw"])' + and ([.data.records[].name] == ["docx", "xlsx", "help", "identify", "outline", "get", "text", "query", "create", "batch", "raw"])' moon run --target wasm office/cmd/office -- help all --jsonl 2>office-wasm.err \ - | jq -se 'length == 9 and all(.[]; .schema == "office.capability/2")' + | jq -se 'length == 11 and all(.[]; .schema == "office.capability/2")' if moon run --target wasm office/cmd/office -- help xlxs --json >office-wasm-error.json 2>office-wasm.err; then echo "expected unknown office help format to fail" exit 1 @@ -332,6 +326,27 @@ jobs: and .data.schema == "office.xlsx.query/1" and .data.matched_total == 4 and [.data.matches[].reference] == ["F11", "G11", "H11", "I11"]' + # Fresh creation and strict batch mutation use the same async, + # bounded transaction path on wasm as native. + xc=$(moon run --target wasm office/cmd/office -- create xlsx ci-office-created.xlsx --sheet Data --json 2>office-wasm.err) || { echo "office XLSX create failed; stderr:"; cat office-wasm.err; exit 1; } + echo "$xc" | jq -e '.success == true + and .data.schema == "office.xlsx.create/1" + and .data.sheet == "Data" + and .data.transaction.committed == true' + cat >ci-office-batch.json <<'JSON' + {"schema":"xlsx.batch/1","ops":[{"op":"set","params":{"sheet":"Data","cell":"A1","value":"wasm"}},{"op":"formula","params":{"sheet":"Data","cell":"B1","formula":"=LEN(A1)"}}]} + JSON + xb=$(moon run --target wasm office/cmd/office -- batch ci-office-created.xlsx ci-office-batch.json --out ci-office-batched.xlsx --json 2>office-wasm.err) || { echo "office XLSX batch failed; stderr:"; cat office-wasm.err; exit 1; } + echo "$xb" | jq -e '.success == true + and .data.schema == "office.xlsx.batch/1" + and .data.stats.operation_count == 2 + and .data.stats.touched_cells == 2 + and .data.transaction.committed == true + and any(.warnings[]; .code == "office.xlsx.full_rewrite")' + xbg=$(moon run --target wasm office/cmd/office -- get ci-office-batched.xlsx '/xlsx/sheet[name="Data"]/range[A1:B1]' --json 2>office-wasm.err) || { echo "office XLSX batch readback failed; stderr:"; cat office-wasm.err; exit 1; } + echo "$xbg" | jq -e '.success == true + and .data.cells[0].raw.value == "wasm" + and .data.cells[1].formula == "LEN(A1)"' # DOCX structured reads share one canonical bounded projection. # Drive every command through the wasm async filesystem path. o=$(moon run --target wasm office/cmd/office -- outline docx2html/tests/cram/fixtures/single-paragraph.docx --json 2>office-wasm.err) || { echo "office DOCX outline failed; stderr:"; cat office-wasm.err; exit 1; } diff --git a/README.mbt.md b/README.mbt.md index ce7f6a48..ac3e4589 100644 --- a/README.mbt.md +++ b/README.mbt.md @@ -324,13 +324,17 @@ moon run office/cmd/office -- outline book.xlsx --json moon run office/cmd/office -- get book.xlsx '/xlsx/sheet[name="Data"]/range[A1:C12]' --json moon run office/cmd/office -- text book.xlsx --under '/xlsx/sheet[name="Data"]' --json moon run office/cmd/office -- query book.xlsx 'cell[type=formula]' --under '/xlsx/sheet[name="Data"]' --json +moon run office/cmd/office -- create xlsx new-book.xlsx --sheet Data --json +moon run office/cmd/office -- batch new-book.xlsx changes.json --out revised.xlsx --json ``` DOCX results use the `office.docx.{outline,element,text,query}/1` family and -XLSX results use `office.xlsx.{outline,element,text,query}/1`; every result is -inside `office.output/1`. See +XLSX reads use `office.xlsx.{outline,element,text,query}/1`; creation and batch +use `office.xlsx.{create,batch}/1`. Every result is inside `office.output/1`. +See [Unified Office DOCX reads](docs/office-docx-read.md), -[Unified Office XLSX reads](docs/office-xlsx-read.md), and +[Unified Office XLSX reads](docs/office-xlsx-read.md), +[Transactional Office XLSX mutations](docs/office-xlsx-mutations.md), and [Canonical Office selectors](docs/office-selectors.md). ## XLSX command-line tool diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..c8a468ed --- /dev/null +++ b/TODO.md @@ -0,0 +1,187 @@ +# OfficeCLI parity handoff + +Last updated: 2026-07-19 (Asia/Shanghai) + +This file is the handoff for the non-PPT OfficeCLI parity effort tracked by +[#139](https://github.com/moonbitlang/office.mbt/issues/139). PowerPoint and MCP +are deliberate non-goals. There are no third-party compatibility obligations, +so future work should prefer a clean, durable contract over compatibility +aliases. + +## Landing state + +- [PR #172](https://github.com/moonbitlang/office.mbt/pull/172) is merged into + `main`. It delivered the bounded unified XLSX read surface (`outline`, `get`, + `text`, and `query`) while retaining authenticated OOXML relationship + discovery, Markup Compatibility processing, cancellation checks, preserved + drawing state, and fail-closed resource accounting. +- [PR #173](https://github.com/moonbitlang/office.mbt/pull/173) is the final + in-flight PR from this work session. It is based on `main` and delivers + transactional XLSX creation and strict batch mutation. If this file is read + on `main`, #173 has landed and [#162](https://github.com/moonbitlang/office.mbt/issues/162) + should be closed. If it is read on the feature branch, finish only #173's + exact-head review and CI; do not add another capability to it. +- No additional implementation PR was opened. The known shared-formula + compatibility problem is isolated in + [#176](https://github.com/moonbitlang/office.mbt/issues/176). + +The current #173 branch is `agent/office-x3-xlsx-batch`; the working tree used +to finish it was `/private/tmp/mbtexcel-office-xlsx-batch`. The reference +OfficeCLI checkout remains `.repos/officecli` in the primary repository +working tree. + +## What is complete after #173 + +The following major-parity foundations are implemented and should not be +rebuilt in later PRs: + +- A1-A5: the umbrella `office` command, versioned capability/help protocol, + canonical selectors, atomic validated transactions, and validated raw OOXML + fallback. +- D1-D2: preservation-safe DOCX edit sessions plus bounded DOCX `outline`, + `get`, `text`, and `query`. +- X1-X2: provenance-checked bounded XLSX reads plus bounded XLSX `outline`, + `get`, `text`, and `query`. +- X3 (#173): `office create xlsx`, strict `xlsx.batch/1` parsing, transactional + XLSX mutation, dry-run/no-replace/overwrite behavior, preservation reports, + bounded serialization, and OpenXML validation. + +Important implementation invariants: + +- File publication uses `moonbitlang/async`; no new C stubs are required. +- Sync and async paths share the same semantic implementation and resource + policy. Async scheduling cooperation inside long parser work is intentionally + deferred to #174 rather than being faked with entry- or sheet-level yields. +- XLSX full rewrites must preserve unsupported drawing anchors, relationships, + and dependent parts already retained by the reader. Generated drawing object + and relationship ids must avoid preserved ids. +- Read limits remain cumulative across a package. Relationship defaults match + the stricter #172 parser boundaries so a workbook written by the library is + not rejected by the default reader; callers can still select lower limits. +- Capability output is schema-driven and snapshot-tested. Never advertise a + command or format variant before its end-to-end implementation exists. + +The detailed contracts live in: + +- `docs/office-major-parity.md` +- `docs/office-xlsx-read.md` +- `docs/office-xlsx-mutations.md` +- `docs/office-docx-read.md` +- `docs/office-transactions.md` +- `docs/agent-json-schemas.md` + +## Remaining work, in recommended order + +Each item below already has an issue. Do not duplicate it. Take one issue per +PR unless the issue itself is first split into independently useful children. + +1. [#176 — valid overlapping shared-formula ranges](https://github.com/moonbitlang/office.mbt/issues/176) + is a small, known compatibility fix. Confirm the OOXML/Excel behavior, allow + valid overlap between distinct shared indexes without weakening same-group + validation, and keep bounded/cancellable materialization. The repository + fixture `fixtures/excelize/test/Book1.xlsx` demonstrates the problem. Remove + any raw-command workaround only after the semantic reader accepts the file. +2. [#163 — D3 fresh DOCX create and batch](https://github.com/moonbitlang/office.mbt/issues/163). + Reuse the existing DOCX writer and the X3 transaction/publication pattern; + do not copy the XLSX engine or mix annotation mutation into this PR. + Header/footer authoring remains separately tracked by #95. +3. [#164 — D4 DOCX annotation mutations](https://github.com/moonbitlang/office.mbt/issues/164). + Build only on the source-pinned D1 edit session and preserve unrelated parts. +4. [#165 — V1 cross-format `validate` and `issues`](https://github.com/moonbitlang/office.mbt/issues/165). + This also subsumes the standalone exit-status gap in #75. +5. [#166 — P1 deterministic HTML/SVG preview](https://github.com/moonbitlang/office.mbt/issues/166). + Static/offline output only; no browser service, watch mode, or screenshot + dependency. +6. [#167 — R1 replayable semantic dump](https://github.com/moonbitlang/office.mbt/issues/167). + Replay must use the canonical create/batch engines rather than a new writer. +7. [#168 — T1 XLSX/DOCX template merge](https://github.com/moonbitlang/office.mbt/issues/168). + Keep the placeholder language strict and non-executable. +8. [#169 — F1 final non-PPT acceptance](https://github.com/moonbitlang/office.mbt/issues/169). + Run only after the child capabilities above have landed. This is the place + for the final fresh-agent discoverability exercise and the ultra review. + +Architecture work that can proceed independently, but must stay in its own PR: + +- [#174 — scheduler-cooperative Office parsing](https://github.com/moonbitlang/office.mbt/issues/174). + Implement pure-MoonBit fuel-bounded resumable machines with sync and async + drivers. Use public `moonbitlang/async` APIs, no C stubs, native threads, + private runtime imports, or duplicated parsers. Scheduling fuel is separate + from security budgets. + +Other existing component issues (#74, #76, #94, #95, and #155) remain open and +must not be silently folded into an Office parity PR. Publication in #155 needs +separate authorization. + +## Small-PR rule + +Do not repeat the size of #172 or #173. A PR should have one root cause or one +user-visible capability, one independently testable acceptance boundary, and a +clear revert story. + +- Do not stack a new capability on an unmerged feature branch. +- Separate reusable foundation, CLI exposure, and unrelated compatibility + fixes when each can land and be useful independently. +- As a review trigger, stop and split when a PR approaches roughly 1,000 + hand-written changed lines or touches more than about 15 production files. + Generated interfaces/snapshots are excluded, but they do not justify mixing + scopes. +- If a review discovers a non-blocking issue outside the stated acceptance + criteria, file an issue and fix it in a new small PR. Do not let it expand the + current PR. +- Commit logical, buildable steps regularly. Avoid one final catch-all commit. + +## Required review and landing protocol + +For every PR: + +1. Rebase on current `origin/main`; do not review a stale stacked base. +2. Run `moon info && moon fmt`, inspect all `.mbti` changes, then run + `moon check` and the focused tests. +3. Run the relevant native, Wasm, and JS gates. For Office CLI changes, build + the native CLI and run `moon cram test office/cmd/office/cram` from the same + stub setup used by CI. Mutation work also needs Microsoft + `DocumentFormat.OpenXml` validation with .NET 8. + Pin all validation runs to one exact commit. To reduce wall time, use a + separate detached worktree (or at least a separate `--target-dir`) for each + target so native, Wasm, and JS can run concurrently without contending on + `.moon-lock`; do not edit the source while those runs are active. +4. Ask a brand-new, ephemeral Codex CLI session to review the exact pushed + head. Use at least `xhigh`; use `max` routinely for cross-package changes + and `ultra` for security architecture or final epic acceptance. Never reuse + the implementation session as the approving reviewer. +5. If the reviewer changes the code, commit the fix and run a new fresh review + over the changed exact head (or a clearly bounded delta ending at it). +6. Merge only when exact-head CI and the fresh review are both green. Update or + close the corresponding issue immediately after landing. + +CI-equivalent local commands are documented in `.github/workflows/ci.yml`. The +complete matrix currently includes: + +```text +moon info +moon fmt +moon check +moon check --target wasm +moon check --target js +moon test --target wasm +moon test --target js +moon test --target native +``` + +The workspace currently resolves `moonbitlang/async@0.20.2`. An async upgrade +is allowed when it has a concrete benefit, but it must be an isolated dependency +PR with native/Wasm scheduler and filesystem tests; do not combine it with a +feature PR. + +## Deliberate deferrals + +- PowerPoint/PPTX. +- MCP, resident mode, and live watch/selection. +- Plugin and language-SDK wrappers. +- Pixel-perfect DOCX pagination, tracked-change authoring, OLE, diagrams, and + other low-frequency long-tail Office features. +- Backwards-compatibility aliases for unreleased APIs. + +Keep `docs/office-major-parity.md`, issue #139, and this handoff synchronized as +work lands. The final acceptance issue #169, not an informal percentage, is the +authority for declaring the non-PPT parity effort complete. diff --git a/batch/batch.mbt b/batch/batch.mbt index ed153a59..10462b19 100644 --- a/batch/batch.mbt +++ b/batch/batch.mbt @@ -14,12 +14,34 @@ /// runaway generator into a clean error instead of an OOM. pub const MAX_BATCH_OPS : Int = 10_000 +///| +/// Maximum encoded size of one strict batch script. This is enforced before +/// UTF-8 decode or JSON parsing by `parse_script_bytes`. +pub const MAX_BATCH_SCRIPT_BYTES : Int = 8 * 1024 * 1024 + ///| /// Aggregate cap on cells touched by `style` range expansions across one /// script: the per-range parser caps each range at 100k cells, but 10k /// such ops would still be a billion mutations — an OOM, not a workload. pub const MAX_TOTAL_STYLE_CELLS : Int64 = 1_000_000 +///| +/// Aggregate cap on direct cell writes, expanded style-range writes, and table +/// header cells that may be synthesized while applying a table operation. +/// Validation and conditional formatting never materialize their ranges and +/// therefore do not consume this counter. +pub const MAX_TOTAL_TOUCHED_CELLS : Int64 = 1_000_000 + +///| +/// Aggregate cap on new workbook style/differential-style records requested by +/// `style` and `cf` operations. +pub const MAX_TOTAL_NEW_STYLES : Int = 4_096 + +///| +/// Maximum lexical length of a JSON number outside strings. Longer spellings +/// hit known upstream rounding edge cases and are never useful workbook input. +pub const MAX_BATCH_NUMBER_TOKEN_CHARS : Int = 40 + ///| /// The sheet grid limits (Excel's maximums), for the `row`/`col` ops' band /// validation. A band `[at, at+count-1]` must fit within `[1, max]`. @@ -34,13 +56,14 @@ const MAX_SHEET_COLS : Int = 16_384 /// agent-supplied `count` is bounded to keep one op's latency in check. /// insert/delete are engine-bounded (they pass `count` straight through) and /// are not subject to this cap. -const MAX_ROWCOL_BAND : Int = 10_000 +pub const MAX_ROW_COLUMN_BAND : Int = 10_000 ///| -/// Aggregate cap on lines touched by `row`/`col` hide/show/height across one -/// script — the row/col analogue of `MAX_TOTAL_STYLE_CELLS`, bounding total -/// setter calls even when each op stays under `MAX_ROWCOL_BAND`. -const MAX_TOTAL_ROWCOL_CELLS : Int64 = 1_000_000 +/// Aggregate cap on dimensions touched by width and `row`/`col` +/// hide/show/height operations across one script — the row/col analogue of +/// `MAX_TOTAL_STYLE_CELLS`, bounding total setter calls even when each op stays +/// locally bounded. +pub const MAX_TOTAL_ROW_COLUMN_LINES : Int64 = 1_000_000 ///| /// A batch script or op that failed validation or application. The @@ -199,19 +222,41 @@ priv enum OpKind { } ///| -/// A parsed batch operation, **opaque by design**. Callers get these from -/// `parse_script` and hand them to `apply_op`; they cannot construct or -/// pattern-match them. This is deliberate: the op vocabulary grows over -/// time (new `xlsx.batch/1` ops), and hiding the representation keeps -/// adding a variant or a field a source-compatible change instead of a -/// breaking one for every downstream `match`. Discover the current op set -/// programmatically via `capabilities()`. -/// -/// A plain (package-private) single-field struct: it appears in the public -/// `parse_script`/`apply_op`/`name` signatures, so it exports as an -/// abstract type — external code holds it but cannot construct or match it. +/// A parsed batch operation, opaque by design. Callers may retain the complete +/// `BatchPlan` or use the lower-level operation API; neither exposes the +/// representation. Discover the exact vocabulary of this build through +/// `capabilities()` instead of assuming compatibility between pre-stable builds. struct BatchOp(OpKind) +///| +/// Resource accounting retained with one parsed batch plan. +pub struct BatchPlanStats { + operation_count : Int + touched_cells : Int64 + style_cells : Int64 + row_column_lines : Int64 + new_style_records : Int +} + +///| +/// An opaque, strictly parsed `xlsx.batch/1` plan. Keeping operations and +/// accounting together prevents callers from validating one array and applying +/// another. +struct BatchPlan { + ops : Array[BatchOp] + stats : BatchPlanStats +} + +///| +pub fn BatchPlan::stats(self : BatchPlan) -> BatchPlanStats { + self.stats +} + +///| +pub fn BatchPlan::operation_count(self : BatchPlan) -> Int { + self.stats.operation_count +} + ///| /// The op's script name (also its CLI subcommand name), for error text. pub fn BatchOp::name(self : BatchOp) -> String { @@ -239,6 +284,119 @@ fn op_error(index : Int, op : String, message : String) -> BatchError { BatchError("op \{index} (\{op}): \{message}") } +///| +/// Reject every enum and action-dependent parameter while the encoded plan is +/// parsed. This keeps malformed scripts outside workbook I/O and ensures the +/// transaction never materializes a source package merely to discover a script +/// grammar error. +fn validate_parsed_op_contract(op : BatchOp) -> Unit raise BatchError { + match op.0 { + OpChart(chart_type~, ..) => ignore(chart_type_of(chart_type)) + OpValidate( + validation_type~, + operator~, + formula1~, + formula2~, + values~, + source~, + error_style~, + .. + ) => + validate_data_validation_contract( + validation_type, operator, formula1, formula2, values, source, error_style, + ) + OpCf( + cf_type~, + criteria~, + value~, + min_value~, + max_value~, + formula~, + fill~, + font_color~, + bold~, + italic~, + min_type~, + mid_type~, + max_type~, + mid_value~, + min_color~, + mid_color~, + max_color~, + bar_color~, + bar_solid~, + bar_direction~, + bar_border_color~, + icon_style~, + reverse_icons~, + stop_if_true~, + .. + ) => + validate_conditional_format_contract( + cf_type, criteria, value, min_value, max_value, formula, fill, font_color, + bold, italic, min_type, mid_type, max_type, mid_value, min_color, mid_color, + max_color, bar_color, bar_solid, bar_direction, bar_border_color, icon_style, + reverse_icons, stop_if_true, + ) + OpSheet(action~, to~, ..) => + match action { + "rename" => + if to == "" { + raise BatchError("sheet: action 'rename' requires 'to'") + } + "delete" | "hide" | "show" | "very-hide" => + if to != "" { + raise BatchError("sheet: action '\{action}' does not take 'to'") + } + other => + raise BatchError( + "sheet: unknown action '\{other}' (expected one of: \{sheet_action_values.join(", ")})", + ) + } + OpRow(action~, height~, ..) => + match action { + "hide" | "show" => + if height is Some(_) { + raise BatchError("row: action '\{action}' does not take 'height'") + } + "height" => + if height is None { + raise BatchError("row: action 'height' requires 'height'") + } + other => + raise BatchError( + "row: unknown action '\{other}' (expected one of: \{row_action_values.join(", ")})", + ) + } + OpCol(action~, ..) => + if !column_action_values.contains(action) { + raise BatchError( + "col: unknown action '\{action}' (expected one of: \{column_action_values.join(", ")})", + ) + } + OpStyle( + border~, + border_top~, + border_bottom~, + border_left~, + border_right~, + border_color~, + .. + ) => + ignore( + build_cell_borders( + all=border, + top=border_top, + bottom=border_bottom, + left=border_left, + right=border_right, + color=border_color, + ), + ) + _ => () + } +} + ///| /// The `row`/`col` actions that apply a per-line setter in a loop (rows have /// no range API), and so consume the per-op / script-wide line budgets. @@ -253,8 +411,10 @@ fn is_looping_action(action : String) -> Bool { /// Validate a `row`/`col` band `[at, at+count-1]` at parse time: /// - it must fit within `[1, axis_max]` (checked overflow-safe: never forms /// `at + count`, which could exceed `Int`); -/// - for a looping action, `count` must stay under `MAX_ROWCOL_BAND`, and the -/// running total across the script must stay under `MAX_TOTAL_ROWCOL_CELLS`. +/// - for a looping action, `count` must stay under `MAX_ROW_COLUMN_BAND`, and +/// the +/// running total across the script must stay under +/// `MAX_TOTAL_ROW_COLUMN_LINES`. /// `at`/`count` are already validated `>= 1` by the readers. The `action` is /// still the raw string (its validity is an apply-time concern), so an /// unknown action is only band-checked here — the apply arm rejects it. @@ -277,30 +437,80 @@ fn validate_rowcol_band( ) } if is_looping_action(action) { - if count > MAX_ROWCOL_BAND { + if count > MAX_ROW_COLUMN_BAND { raise op_error( reader.index, reader.op, - "'count' of \{count} exceeds the per-op \{action} limit of \{MAX_ROWCOL_BAND} \{axis}s", + "'count' of \{count} exceeds the per-op \{action} limit of \{MAX_ROW_COLUMN_BAND} \{axis}s", ) } rowcol_cells.val = rowcol_cells.val + count.to_int64() - if rowcol_cells.val > MAX_TOTAL_ROWCOL_CELLS { + if rowcol_cells.val > MAX_TOTAL_ROW_COLUMN_LINES { raise op_error( reader.index, reader.op, - "row/col hide/show/height touch past \{MAX_TOTAL_ROWCOL_CELLS} total lines", + "row/col hide/show/height touch past \{MAX_TOTAL_ROW_COLUMN_LINES} total lines", ) } } } ///| -/// Parses a full `xlsx.batch/1` script document into ops. Strict: the -/// envelope must be an object holding exactly `schema` and `ops`, the -/// schema string must match, and every op must validate. Zero ops is a -/// valid no-op. -pub fn parse_script(document : Json) -> Array[BatchOp] raise BatchError { +fn check_number_token_lengths(text : String) -> Unit raise BatchError { + let mut in_string = false + let mut escaped = false + let mut run = 0 + for index in 0.. MAX_BATCH_NUMBER_TOKEN_CHARS { + raise BatchError( + "numeric literal longer than \{MAX_BATCH_NUMBER_TOKEN_CHARS} characters", + ) + } + } else { + run = 0 + } + } +} + +///| +/// Parses encoded `xlsx.batch/1` bytes under the script-size, UTF-8, numeric +/// token, JSON and operation-resource boundaries in that order. +pub fn parse_script_bytes(data : BytesView) -> BatchPlan raise BatchError { + if data.length() > MAX_BATCH_SCRIPT_BYTES { + raise BatchError( + "batch script has \{data.length()} bytes; max is \{MAX_BATCH_SCRIPT_BYTES}", + ) + } + let text = @utf8.decode(data, ignore_bom=true) catch { + _ => raise BatchError("batch script is not valid UTF-8") + } + check_number_token_lengths(text) + let document = @json.parse(text) catch { + error => raise BatchError("invalid batch JSON: \{error}") + } + parse_plan(document) +} + +///| +/// Parses a full `xlsx.batch/1` JSON document into one opaque plan. Strict: +/// the envelope must be an object holding exactly `schema` and `ops`, the +/// schema string must match, and every op must validate. Zero ops is a valid +/// no-op. +fn parse_plan(document : Json) -> BatchPlan raise BatchError { let root = match document { Object(map) => map _ => raise BatchError("batch script must be a JSON object") @@ -333,11 +543,33 @@ pub fn parse_script(document : Json) -> Array[BatchOp] raise BatchError { } let ops = [] let style_cells : Ref[Int64] = Ref(0) + let touched_cells : Ref[Int64] = Ref(0) let rowcol_cells : Ref[Int64] = Ref(0) + let new_styles : Ref[Int] = Ref(0) for index, item in items { - ops.push(parse_op(index, item, style_cells, rowcol_cells)) + ops.push( + parse_op( + index, item, style_cells, touched_cells, rowcol_cells, new_styles, + ), + ) + } + { + ops, + stats: { + operation_count: ops.length(), + touched_cells: touched_cells.val, + style_cells: style_cells.val, + row_column_lines: rowcol_cells.val, + new_style_records: new_styles.val, + }, } - ops +} + +///| +/// Lower-level JSON parser for callers that require per-operation control. +/// Transactional callers should retain and apply the complete `BatchPlan`. +pub fn parse_script(document : Json) -> Array[BatchOp] raise BatchError { + parse_plan(document).ops.copy() } ///| @@ -345,7 +577,9 @@ fn parse_op( index : Int, item : Json, style_cells : Ref[Int64], + touched_cells : Ref[Int64], rowcol_cells : Ref[Int64], + new_styles : Ref[Int], ) -> BatchOp raise BatchError { let entry = match item { Object(map) => map @@ -409,6 +643,14 @@ fn parse_op( "style ranges expand past \{MAX_TOTAL_STYLE_CELLS} total cells", ) } + touched_cells.val = touched_cells.val + cells + if touched_cells.val > MAX_TOTAL_TOUCHED_CELLS { + raise op_error( + index, + name, + "cell mutations expand past \{MAX_TOTAL_TOUCHED_CELLS} total cells", + ) + } OpStyle( sheet=reader.string("sheet"), range~, @@ -428,12 +670,22 @@ fn parse_op( } "merge" => OpMerge(sheet=reader.string("sheet"), range=reader.colon_range("range")) - "width" => + "width" => { + let (column, lines) = reader.column("column") + rowcol_cells.val = rowcol_cells.val + lines.to_int64() + if rowcol_cells.val > MAX_TOTAL_ROW_COLUMN_LINES { + raise op_error( + index, + name, + "row/column mutations touch past \{MAX_TOTAL_ROW_COLUMN_LINES} total lines", + ) + } OpWidth( sheet=reader.string("sheet"), - column=reader.column("column"), + column~, width=reader.number("width"), ) + } "freeze" => OpFreeze(sheet=reader.string("sheet"), cell=reader.cell("cell")) "filter" => OpFilter(sheet=reader.string("sheet"), range=reader.colon_range("range")) @@ -448,10 +700,19 @@ fn parse_op( series_name=reader.optional_string("name"), title=reader.optional_string("title"), ) - "table" => + "table" => { + let (range, header_cells) = reader.table_range("range") + touched_cells.val = touched_cells.val + header_cells + if touched_cells.val > MAX_TOTAL_TOUCHED_CELLS { + raise op_error( + index, + name, + "cell mutations expand past \{MAX_TOTAL_TOUCHED_CELLS} total cells", + ) + } OpTable( sheet=reader.string("sheet"), - range=reader.table_range("range"), + range~, name=reader.optional_string("name"), style=reader.optional_string("style"), header_row=reader.tristate_bool("header_row"), @@ -460,6 +721,7 @@ fn parse_op( last_column=reader.optional_bool("last_column"), column_stripes=reader.optional_bool("column_stripes"), ) + } "validate" => OpValidate( sheet=reader.string("sheet"), @@ -553,6 +815,45 @@ fn parse_op( ) } reader.finish() + validate_parsed_op_contract(op) catch { + BatchError(message) => raise op_error(index, name, message) + } + match op { + OpSet(..) | OpFormula(..) => { + touched_cells.val = touched_cells.val + 1L + if touched_cells.val > MAX_TOTAL_TOUCHED_CELLS { + raise op_error( + index, + name, + "cell mutations expand past \{MAX_TOTAL_TOUCHED_CELLS} total cells", + ) + } + } + OpStyle(..) => { + new_styles.val = new_styles.val + 1 + if new_styles.val > MAX_TOTAL_NEW_STYLES { + raise op_error( + index, + name, + "new style records exceed \{MAX_TOTAL_NEW_STYLES} per script", + ) + } + } + OpCf(fill~, font_color~, bold~, italic~, ..) if fill != "" || + font_color != "" || + bold is Some(true) || + italic is Some(true) => { + new_styles.val = new_styles.val + 1 + if new_styles.val > MAX_TOTAL_NEW_STYLES { + raise op_error( + index, + name, + "new style records exceed \{MAX_TOTAL_NEW_STYLES} per script", + ) + } + } + _ => () + } BatchOp(op) } @@ -754,14 +1055,15 @@ fn ParamReader::colon_range( /// syntax but WITHOUT the 100k-cell expansion cap that `range`/`colon_range` /// apply. A table never enumerates its cells (the engine derives columns /// from the header row only), so an ordinary large table range must be -/// accepted rather than rejected as an expansion. Also rejects a single-row -/// range on the last grid row, which the engine expands downward by one row -/// and would push off the grid — caught here with a clear message instead of -/// failing deep in apply. +/// accepted rather than rejected as an expansion. The returned width charges +/// the aggregate materialization budget because missing header cells may be +/// synthesized. Also rejects a single-row range on the last grid row, which +/// the engine expands downward by one row and would push off the grid — caught +/// here with a clear message instead of failing deep in apply. fn ParamReader::table_range( self : ParamReader, key : String, -) -> String raise BatchError { +) -> (String, Int64) raise BatchError { let text = self.string(key) let range = @inspect.parse_range_unbounded(text) catch { _ => @@ -787,7 +1089,7 @@ fn ParamReader::table_range( "'\{key}' is a single-row range on the last grid row, leaving no room for a data row: '\{text}'", ) } - text + (text, (range.col_hi - range.col_lo + 1).to_int64()) } ///| @@ -874,10 +1176,13 @@ fn ParamReader::range( fn ParamReader::column( self : ParamReader, key : String, -) -> String raise BatchError { +) -> (String, Int) raise BatchError { let text = self.string(key) - match text.find(":") { - None => ignore(self.column_number(key, text, text)) + let count = match text.find(":") { + None => { + ignore(self.column_number(key, text, text)) + 1 + } Some(index) => { let lo = self.column_number(key, text[:index].to_owned(), text) let hi = self.column_number(key, text[index + 1:].to_owned(), text) @@ -888,9 +1193,10 @@ fn ParamReader::column( "'\{key}' is not a valid column or column range: '\{text}'", ) } + hi - lo + 1 } } - text + (text, count) } ///| @@ -1708,3 +2014,20 @@ pub fn apply_op(workbook : @xlsx.Workbook, op : BatchOp) -> Unit raise { } } } + +///| +/// Applies every operation in order to an in-memory workbook. An application +/// failure is normalized to `BatchError` with the 0-based operation index and +/// operation name. Callers retain all-or-nothing semantics by discarding this +/// workbook unless subsequent serialization, validation and publication pass. +pub fn BatchPlan::apply( + self : BatchPlan, + workbook : @xlsx.Workbook, +) -> Unit raise BatchError { + for index, op in self.ops { + apply_op(workbook, op) catch { + BatchError(message) => raise op_error(index, op.name(), message) + error => raise op_error(index, op.name(), error.to_string()) + } + } +} diff --git a/batch/batch_test.mbt b/batch/batch_test.mbt index db4a7273..a5d6a3de 100644 --- a/batch/batch_test.mbt +++ b/batch/batch_test.mbt @@ -7,6 +7,11 @@ fn parse_ops(script : String) -> Array[@batch.BatchOp] raise { @batch.parse_script(@json.parse(script)) } +///| +fn parse_plan_json(document : Json) -> @batch.BatchPlan raise @batch.BatchError { + @batch.parse_script_bytes(@utf8.encode(document.stringify())) +} + ///| fn apply_script(workbook : @xlsx.Workbook, script : String) -> Int raise { let ops = parse_ops(script) @@ -16,6 +21,232 @@ fn apply_script(workbook : @xlsx.Workbook, script : String) -> Int raise { ops.length() } +///| +test "opaque batch plans retain deterministic resource accounting" { + let script = + #|{ + #| "schema": "xlsx.batch/1", + #| "ops": [ + #| {"op":"set","params":{"sheet":"Data","cell":"A1","value":1}}, + #| {"op":"formula","params":{"sheet":"Data","cell":"A2","formula":"=A1"}}, + #| {"op":"style","params":{"sheet":"Data","range":"A1:B2","bold":true}}, + #| {"op":"row","params":{"sheet":"Data","action":"hide","at":2,"count":3}}, + #| {"op":"cf","params":{"sheet":"Data","range":"A1","type":"formula","formula":"=A1>0","fill":"FF0000"}} + #| ] + #|} + let plan = @batch.parse_script_bytes(@utf8.encode(script)) + let stats = plan.stats() + assert_eq(plan.operation_count(), 5) + assert_eq(stats.operation_count, 5) + assert_eq(stats.touched_cells, 6L) + assert_eq(stats.style_cells, 4L) + assert_eq(stats.row_column_lines, 3L) + assert_eq(stats.new_style_records, 2) +} + +///| +test "width ranges consume the aggregate row/column materialization budget" { + let accepted = parse_plan_json( + Json::object({ + "schema": Json::string("xlsx.batch/1"), + "ops": Json::array([ + Json::object({ + "op": Json::string("width"), + "params": Json::object({ + "sheet": Json::string("S"), + "column": Json::string("A:C"), + "width": Json::number(12.0), + }), + }), + ]), + }), + ) + assert_eq(accepted.stats().row_column_lines, 3L) + + let ops = [] + for _ in 0..<62 { + ops.push( + Json::object({ + "op": Json::string("width"), + "params": Json::object({ + "sheet": Json::string("S"), + "column": Json::string("A:XFD"), + "width": Json::number(12.0), + }), + }), + ) + } + try + parse_plan_json( + Json::object({ + "schema": Json::string("xlsx.batch/1"), + "ops": Json::array(ops), + }), + ) + catch { + BatchError(message) => + assert_true(message.contains("row/column mutations touch past")) + } noraise { + _ => fail("expected width ranges to consume the row/column budget") + } +} + +///| +test "encoded batch boundary rejects oversized, invalid UTF-8 and long numbers" { + try + @batch.parse_script_bytes( + Bytes::make(@batch.MAX_BATCH_SCRIPT_BYTES + 1, b' '), + ) + catch { + BatchError(message) => + assert_true(message.contains("max is \{@batch.MAX_BATCH_SCRIPT_BYTES}")) + } noraise { + _ => fail("expected oversized script rejection") + } + try @batch.parse_script_bytes(Bytes::from_array([b'\xFF'])) catch { + BatchError(message) => assert_eq(message, "batch script is not valid UTF-8") + } noraise { + _ => fail("expected invalid UTF-8 rejection") + } + let long_number = "1".repeat(@batch.MAX_BATCH_NUMBER_TOKEN_CHARS + 1) + try + @batch.parse_script_bytes( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"S\",\"cell\":\"A1\",\"value\":\{long_number}}}]}", + ), + ) + catch { + BatchError(message) => assert_true(message.contains("numeric literal")) + } noraise { + _ => fail("expected long numeric token rejection") + } + // The lexical limit deliberately ignores number-like text inside strings. + let quoted = "1".repeat(@batch.MAX_BATCH_NUMBER_TOKEN_CHARS + 1) + let accepted = @batch.parse_script_bytes( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"S\",\"cell\":\"A1\",\"value\":\"\{quoted}\"}}]}", + ), + ) + assert_eq(accepted.operation_count(), 1) +} + +///| +test "batch plan apply normalizes operation index and name" { + let plan = @batch.parse_script_bytes( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"Missing\",\"cell\":\"A1\",\"value\":1}}]}", + ), + ) + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Data")) + try plan.apply(workbook) catch { + BatchError(message) => { + assert_true(message.has_prefix("op 0 (set):")) + assert_true(message.length() > "op 0 (set):".length()) + } + } noraise { + _ => fail("expected missing-sheet application failure") + } +} + +///| +test "batch plans cap aggregate direct and expanded cell writes" { + let ops = [] + for _ in 0..<10 { + ops.push( + Json::object({ + "op": Json::string("style"), + "params": Json::object({ + "sheet": Json::string("S"), + "range": Json::string("A1:A100000"), + }), + }), + ) + } + ops.push( + Json::object({ + "op": Json::string("set"), + "params": Json::object({ + "sheet": Json::string("S"), + "cell": Json::string("B1"), + "value": Json::number(1), + }), + }), + ) + try + parse_plan_json( + Json::object({ + "schema": Json::string("xlsx.batch/1"), + "ops": Json::array(ops), + }), + ) + catch { + BatchError(message) => { + assert_true(message.contains("op 10 (set)")) + assert_true(message.contains("total cells")) + } + } noraise { + _ => fail("expected aggregate touched-cell rejection") + } +} + +///| +test "batch plans cap aggregate style records" { + let ops = [] + for _ in 0..<=@batch.MAX_TOTAL_NEW_STYLES { + ops.push( + Json::object({ + "op": Json::string("style"), + "params": Json::object({ + "sheet": Json::string("S"), + "range": Json::string("A1"), + }), + }), + ) + } + try + parse_plan_json( + Json::object({ + "schema": Json::string("xlsx.batch/1"), + "ops": Json::array(ops), + }), + ) + catch { + BatchError(message) => { + assert_true(message.contains("op \{@batch.MAX_TOTAL_NEW_STYLES} (style)")) + assert_true(message.contains("new style records")) + } + } noraise { + _ => fail("expected aggregate style-record rejection") + } +} + +///| +test "style-free conditional formats do not consume differential-style budget" { + let ops = [] + for index in 0..<=@batch.MAX_TOTAL_NEW_STYLES { + ops.push( + Json::object({ + "op": Json::string("cf"), + "params": Json::object({ + "sheet": Json::string("S"), + "range": Json::string("A\{index + 1}"), + "type": Json::string("data_bar"), + "bar_color": Json::string("638EC6"), + }), + }), + ) + } + let plan = parse_plan_json( + Json::object({ + "schema": Json::string("xlsx.batch/1"), + "ops": Json::array(ops), + }), + ) + assert_eq(plan.operation_count(), @batch.MAX_TOTAL_NEW_STYLES + 1) + assert_eq(plan.stats().new_style_records, 0) +} + ///| test "a script covering every op kind applies like its CLI twins" { let workbook = @xlsx.Workbook::new() @@ -187,7 +418,7 @@ test "a style op can border a single side (a header underline)" { } ///| -test "a style op rejects an unknown border style or a bad border color" { +test "a style op rejects invalid border input while parsing" { let cases = [ ( ( @@ -210,7 +441,10 @@ test "a style op rejects an unknown border style or a bad border color" { ignore(apply_script(workbook, script)) fail("expected apply to reject: \{want}") } catch { - @batch.BatchError(message) => assert_eq(message, want) + @batch.BatchError(message) => { + assert_true(message.has_prefix("op 0 (style): ")) + assert_true(message.has_suffix(want)) + } err => fail("expected a BatchError, got \{err}") } } @@ -267,6 +501,8 @@ test "a table op auto-names and keeps engine defaults when options are omitted" #|{"schema":"xlsx.batch/1","ops":[ #| {"op":"table","params":{"sheet":"S","range":"A1:C3"}} #|]} + let plan = @batch.parse_script_bytes(@utf8.encode(script)) + assert_eq(plan.stats().touched_cells, 3L) inspect(apply_script(workbook, script), content="1") let table = first_table(workbook) inspect(table.name, content="Table1") @@ -281,6 +517,18 @@ test "a table op auto-names and keeps engine defaults when options are omitted" assert_false(table.show_first_column) // Bool default -> false } +///| +test "table header synthesis is included in aggregate materialization stats" { + let script = + #|{"schema":"xlsx.batch/1","ops":[ + #| {"op":"table","params":{"sheet":"S","range":"A1:XFD2"}}, + #| {"op":"table","params":{"sheet":"S","range":"A4:XFD5"}}, + #| {"op":"table","params":{"sheet":"S","range":"A7:XFD8"}} + #|]} + let plan = @batch.parse_script_bytes(@utf8.encode(script)) + assert_eq(plan.stats().touched_cells, 49_152L) +} + ///| test "a table op accepts a large range — no expansion cap applies" { let workbook = @xlsx.Workbook::new() @@ -447,7 +695,7 @@ test "a validate op reports the workbook stays valid OOXML" { } ///| -test "validate op apply-time errors are typed and specific" { +test "validate op parse-time errors are typed and specific" { let cases = [ ( ( @@ -539,7 +787,10 @@ test "validate op apply-time errors are typed and specific" { ignore(apply_script(workbook, script)) fail("expected apply to reject: \{want}") } catch { - @batch.BatchError(message) => assert_eq(message, want) + @batch.BatchError(message) => { + assert_true(message.has_prefix("op 0 (validate): ")) + assert_true(message.has_suffix(want)) + } err => fail("expected a BatchError, got \{err}") } // The rejected op left no validation behind. @@ -673,7 +924,7 @@ test "stacked cf ops on one range get increasing priorities and stay valid" { } ///| -test "cf op apply-time errors are typed and specific" { +test "cf op parse-time errors are typed and specific" { let cases = [ ( ( @@ -807,7 +1058,10 @@ test "cf op apply-time errors are typed and specific" { ignore(apply_script(workbook, script)) fail("expected apply to reject: \{want}") } catch { - @batch.BatchError(message) => assert_eq(message, want) + @batch.BatchError(message) => { + assert_true(message.has_prefix("op 0 (cf): ")) + assert_true(message.has_suffix(want)) + } err => fail("expected a BatchError, got \{err}") } assert_eq(workbook.sheets()[0].conditional_formats().length(), 0) @@ -855,7 +1109,7 @@ test "a sheet op renames, deletes, hides, very-hides, and shows sheets" { } ///| -test "sheet op validation errors are typed and specific" { +test "sheet op grammar errors are rejected while parsing" { let cases = [ ( ( @@ -885,7 +1139,10 @@ test "sheet op validation errors are typed and specific" { ignore(apply_script(workbook, script)) fail("expected apply to reject: \{want}") } catch { - @batch.BatchError(message) => assert_eq(message, want) + @batch.BatchError(message) => { + assert_true(message.has_prefix("op 0 (sheet): ")) + assert_true(message.has_suffix(want)) + } err => fail("expected a BatchError, got \{err}") } // The two sheets are untouched by a rejected op. @@ -1132,7 +1389,7 @@ test "row and col band ops touch exactly the band" { ///| test "row and col op validation errors are typed and specific" { let cases = [ - // Apply-time action/param rules (bare "row:"/"col:" messages). + // Action/param rules are normalized at the parse boundary. ( ( #|{"schema":"xlsx.batch/1","ops":[{"op":"row","params":{"sheet":"Grid","action":"freeze","at":1}}]} @@ -1197,7 +1454,10 @@ test "row and col op validation errors are typed and specific" { ignore(apply_script(workbook, script)) fail("expected apply to reject: \{want}") } catch { - @batch.BatchError(message) => assert_eq(message, want) + @batch.BatchError(message) => { + assert_true(message.has_prefix("op 0 (")) + assert_true(message.has_suffix(want)) + } err => fail("expected a BatchError, got \{err}") } } diff --git a/batch/capabilities.mbt b/batch/capabilities.mbt index ae1f3f8e..3862dc5e 100644 --- a/batch/capabilities.mbt +++ b/batch/capabilities.mbt @@ -1,9 +1,8 @@ -// The machine-readable capability descriptor for `xlsx.batch/1`: the ops -// this build accepts and their params. An agent generating a script can -// query it (`xlsx capabilities`) and adapt, instead of guessing whether a -// given CLI supports a given op. This is the forward-compatibility escape -// hatch — new ops appear here on newer builds; the JSON script contract -// itself stays backward-compatible. +// The machine-readable capability descriptor for `xlsx.batch/1`: the ops and +// parameters accepted by this exact build. Agents query it (`xlsx +// capabilities`) instead of assuming a vocabulary or limit set. Before the +// schema is declared stable, newer builds may intentionally revise the +// contract without compatibility aliases. ///| /// Schema id of the `capabilities` payload (see docs/agent-json-schemas.md). @@ -15,6 +14,7 @@ priv struct ParamSpec { name : String kind : String required : Bool + allowed_values : Array[String] } ///| @@ -29,12 +29,22 @@ priv struct OpSpec { ///| fn req(name : String, kind : String) -> ParamSpec { - { name, kind, required: true } + { name, kind, required: true, allowed_values: [] } } ///| fn opt(name : String, kind : String) -> ParamSpec { - { name, kind, required: false } + { name, kind, required: false, allowed_values: [] } +} + +///| +fn req_choice(name : String, allowed_values : Array[String]) -> ParamSpec { + { name, kind: "string", required: true, allowed_values } +} + +///| +fn opt_choice(name : String, allowed_values : Array[String]) -> ParamSpec { + { name, kind: "string", required: false, allowed_values } } ///| @@ -64,11 +74,11 @@ let op_specs : Array[OpSpec] = [ opt("fill", "string"), opt("font_color", "string"), opt("align", "string"), - opt("border", "string"), - opt("border_top", "string"), - opt("border_bottom", "string"), - opt("border_left", "string"), - opt("border_right", "string"), + opt_choice("border", border_style_values), + opt_choice("border_top", border_style_values), + opt_choice("border_bottom", border_style_values), + opt_choice("border_left", border_style_values), + opt_choice("border_right", border_style_values), opt("border_color", "string"), ], }, @@ -94,7 +104,7 @@ let op_specs : Array[OpSpec] = [ req("anchor", "cell"), req("categories", "series_range"), req("values", "series_range"), - opt("type", "string"), + opt_choice("type", chart_type_values), opt("name", "string"), opt("title", "string"), ], @@ -118,8 +128,8 @@ let op_specs : Array[OpSpec] = [ params: [ req("sheet", "string"), req("range", "sqref"), - req("type", "string"), - opt("operator", "string"), + req_choice("type", validation_type_values), + opt_choice("operator", validation_operator_values), opt("formula1", "string"), opt("formula2", "string"), opt("values", "string_array"), @@ -129,13 +139,13 @@ let op_specs : Array[OpSpec] = [ opt("input_message", "string"), opt("error_title", "string"), opt("error_message", "string"), - opt("error_style", "string"), + opt_choice("error_style", validation_error_style_values), ], }, { op: "sheet", params: [ - req("action", "string"), + req_choice("action", sheet_action_values), req("name", "string"), opt("to", "string"), ], @@ -144,7 +154,7 @@ let op_specs : Array[OpSpec] = [ op: "row", params: [ req("sheet", "string"), - req("action", "string"), + req_choice("action", row_action_values), req("at", "number"), opt("count", "number"), opt("height", "number"), @@ -154,7 +164,7 @@ let op_specs : Array[OpSpec] = [ op: "col", params: [ req("sheet", "string"), - req("action", "string"), + req_choice("action", column_action_values), req("at", "column"), opt("count", "number"), ], @@ -164,7 +174,7 @@ let op_specs : Array[OpSpec] = [ params: [ req("sheet", "string"), req("range", "sqref"), - req("type", "string"), + req_choice("type", cf_supported_types), opt("criteria", "string"), opt("value", "string"), opt("min_value", "string"), @@ -174,18 +184,18 @@ let op_specs : Array[OpSpec] = [ opt("font_color", "string"), opt("bold", "bool"), opt("italic", "bool"), - opt("min_type", "string"), - opt("mid_type", "string"), - opt("max_type", "string"), + opt_choice("min_type", cf_stop_type_values), + opt_choice("mid_type", cf_stop_type_values), + opt_choice("max_type", cf_stop_type_values), opt("mid_value", "string"), opt("min_color", "string"), opt("mid_color", "string"), opt("max_color", "string"), opt("bar_color", "string"), opt("bar_solid", "bool"), - opt("bar_direction", "string"), + opt_choice("bar_direction", cf_bar_direction_values), opt("bar_border_color", "string"), - opt("icon_style", "string"), + opt_choice("icon_style", cf_classic_icon_styles), opt("reverse_icons", "bool"), opt("stop_if_true", "bool"), ], @@ -240,6 +250,9 @@ pub fn capabilities() -> Json { "name": Json::string(p.name), "type": Json::string(p.kind), "required": Json::boolean(p.required), + "allowed_values": Json::array( + p.allowed_values.map(value => Json::string(value)), + ), }), ) } @@ -254,8 +267,15 @@ pub fn capabilities() -> Json { "schema": Json::string(SCHEMA_CAPABILITIES), "batch_schema": Json::string(@inspect.SCHEMA_BATCH), "limits": Json::object({ + "max_script_bytes": Json::number(MAX_BATCH_SCRIPT_BYTES.to_double()), "max_ops": Json::number(MAX_BATCH_OPS.to_double()), + "max_touched_cells": Json::number(MAX_TOTAL_TOUCHED_CELLS.to_double()), "max_style_cells": Json::number(MAX_TOTAL_STYLE_CELLS.to_double()), + "max_new_styles": Json::number(MAX_TOTAL_NEW_STYLES.to_double()), + "max_row_column_band": Json::number(MAX_ROW_COLUMN_BAND.to_double()), + "max_row_column_lines": Json::number( + MAX_TOTAL_ROW_COLUMN_LINES.to_double(), + ), }), "ops": Json::array(ops), }) diff --git a/batch/capabilities_wbtest.mbt b/batch/capabilities_wbtest.mbt index c33341cf..e3f9aaa8 100644 --- a/batch/capabilities_wbtest.mbt +++ b/batch/capabilities_wbtest.mbt @@ -21,13 +21,15 @@ fn required_samples() -> Map[String, String] { "chart", "{\"sheet\":\"S\",\"anchor\":\"E2\",\"categories\":\"A1:A3\",\"values\":\"B1:B3\"}", ), ("table", "{\"sheet\":\"S\",\"range\":\"A1:B2\"}"), - ("validate", "{\"sheet\":\"S\",\"range\":\"A1\",\"type\":\"list\"}"), + ( + "validate", "{\"sheet\":\"S\",\"range\":\"A1\",\"type\":\"list\",\"source\":\"Sheet2!A1:A2\"}", + ), ("sheet", "{\"action\":\"delete\",\"name\":\"S\"}"), // `hide` needs no `height`, so this required sample stays applicable; the // catalog-built full sample (all params, action="S") only has to PARSE. ("row", "{\"sheet\":\"S\",\"action\":\"hide\",\"at\":2}"), ("col", "{\"sheet\":\"S\",\"action\":\"hide\",\"at\":\"B\"}"), - ("cf", "{\"sheet\":\"S\",\"range\":\"A1\",\"type\":\"cell\"}"), + ("cf", "{\"sheet\":\"S\",\"range\":\"A1\",\"type\":\"data_bar\"}"), ]) } @@ -37,68 +39,11 @@ fn one_op_script(name : String, params : String) -> String { } ///| -/// A valid JSON value for a catalog param `type` label. -fn value_for_type(kind : String) -> Json { - match kind { - "cell" => Json::string("A1") - "range" => Json::string("A1") - "colon_range" => Json::string("A1:B2") - "table_range" => Json::string("A1:B2") - "sqref" => Json::string("A1") - "column" => Json::string("A") - "series_range" => Json::string("A1:A3") - "number" => Json::number(10) - "bool" => Json::boolean(true) - "value" => Json::number(1) - "string_array" => Json::array([Json::string("a")]) - _ => Json::string("S") // "string" and any future scalar label - } -} - -///| -/// A params object providing EVERY catalog param of `op` (required and -/// optional) with a type-valid value, read from the emitted catalog so it -/// cannot drift from what `capabilities` advertises. -fn full_sample(op : String) -> String { - let ops = match capabilities() { - Object(m) => - match m.get("ops") { - Some(Array(items)) => items - _ => abort("no ops") - } - _ => abort("bad payload") - } - let params : Map[String, Json] = Map([]) - for entry in ops { - match entry { - Object(o) => - if o.get("op") is Some(String(n)) && n == op { - match o.get("params") { - Some(Array(ps)) => - for p in ps { - match p { - Object(pm) => - match (pm.get("name"), pm.get("type")) { - (Some(String(pn)), Some(String(pt))) => - params[pn] = value_for_type(pt) - _ => () - } - _ => () - } - } - _ => () - } - } - _ => () - } - } - Json::object(params).stringify() -} ///| -/// Field-exact snapshot of the emitted catalog: `op: pname:ptype[!] …` per -/// op (`!` = required). Any param name, type, requiredness, or op change -/// surfaces here — not just count changes. +/// Field-exact snapshot of the emitted catalog: `op: pname:ptype[!][=values]` +/// per op (`!` = required). Any param name, type, requiredness, allowed-value +/// grammar, or op change surfaces here — not just count changes. test "capabilities payload is field-exact" { let obj = match capabilities() { Object(map) => map @@ -141,7 +86,25 @@ test "capabilities payload is field-exact" { _ => fail("param type") } let req = pm.get("required") is Some(True) - parts.push("\{pn}:\{pt}\{if req { "!" } else { "" }}") + let choices = match pm.get("allowed_values") { + Some(Array(values)) => { + let names = [] + for value in values { + match value { + String(name) => names.push(name) + _ => fail("allowed value") + } + } + names + } + _ => fail("allowed_values") + } + let choice_text = if choices.is_empty() { + "" + } else { + "=\{choices.join("|")}" + } + parts.push("\{pn}:\{pt}\{if req { "!" } else { "" }}\{choice_text}") } lines.push("\{name}: \{parts.join(" ")}") } @@ -150,23 +113,60 @@ test "capabilities payload is field-exact" { content=( #|set: sheet:string! cell:cell! value:value! #|formula: sheet:string! cell:cell! formula:string! - #|style: sheet:string! range:range! bold:bool italic:bool number_format:string fill:string font_color:string align:string border:string border_top:string border_bottom:string border_left:string border_right:string border_color:string + #|style: sheet:string! range:range! bold:bool italic:bool number_format:string fill:string font_color:string align:string border:string=thin|medium|thick|dashed|dotted|double|hair border_top:string=thin|medium|thick|dashed|dotted|double|hair border_bottom:string=thin|medium|thick|dashed|dotted|double|hair border_left:string=thin|medium|thick|dashed|dotted|double|hair border_right:string=thin|medium|thick|dashed|dotted|double|hair border_color:string #|merge: sheet:string! range:colon_range! #|width: sheet:string! column:column! width:number! #|freeze: sheet:string! cell:cell! #|filter: sheet:string! range:colon_range! #|add-sheet: name:string! - #|chart: sheet:string! anchor:cell! categories:series_range! values:series_range! type:string name:string title:string + #|chart: sheet:string! anchor:cell! categories:series_range! values:series_range! type:string=col|column|bar|line|pie|area|scatter|doughnut|donut|radar name:string title:string #|table: sheet:string! range:table_range! name:string style:string header_row:bool row_stripes:bool first_column:bool last_column:bool column_stripes:bool - #|validate: sheet:string! range:sqref! type:string! operator:string formula1:string formula2:string values:string_array source:string allow_blank:bool input_title:string input_message:string error_title:string error_message:string error_style:string - #|sheet: action:string! name:string! to:string - #|row: sheet:string! action:string! at:number! count:number height:number - #|col: sheet:string! action:string! at:column! count:number - #|cf: sheet:string! range:sqref! type:string! criteria:string value:string min_value:string max_value:string formula:string fill:string font_color:string bold:bool italic:bool min_type:string mid_type:string max_type:string mid_value:string min_color:string mid_color:string max_color:string bar_color:string bar_solid:bool bar_direction:string bar_border_color:string icon_style:string reverse_icons:bool stop_if_true:bool + #|validate: sheet:string! range:sqref! type:string!=list|whole|decimal|date|time|textLength|custom operator:string=between|notBetween|equal|notEqual|greaterThan|greaterThanOrEqual|lessThan|lessThanOrEqual formula1:string formula2:string values:string_array source:string allow_blank:bool input_title:string input_message:string error_title:string error_message:string error_style:string=stop|warning|information + #|sheet: action:string!=rename|delete|hide|show|very-hide name:string! to:string + #|row: sheet:string! action:string!=hide|show|height at:number! count:number height:number + #|col: sheet:string! action:string!=hide|show at:column! count:number + #|cf: sheet:string! range:sqref! type:string!=cell|formula|2_color_scale|3_color_scale|data_bar|icon_set criteria:string value:string min_value:string max_value:string formula:string fill:string font_color:string bold:bool italic:bool min_type:string=num|min|max|percent|percentile|formula mid_type:string=num|min|max|percent|percentile|formula max_type:string=num|min|max|percent|percentile|formula mid_value:string min_color:string mid_color:string max_color:string bar_color:string bar_solid:bool bar_direction:string=leftToRight|rightToLeft bar_border_color:string icon_style:string=3Arrows|3ArrowsGray|3Flags|3Signs|3Symbols|3Symbols2|3TrafficLights1|3TrafficLights2|4Arrows|4ArrowsGray|4Rating|4RedToBlack|4TrafficLights|5Arrows|5ArrowsGray|5Quarters|5Rating reverse_icons:bool stop_if_true:bool ), ) } +///| +test "capabilities expose every command-wide batch resource ceiling" { + guard capabilities() is Object(root) && + root.get("limits") is Some(Object(limits)) else { + fail("capabilities limits must be an object") + } + assert_eq(limits.length(), 7) + assert_eq( + limits.get("max_script_bytes"), + Some(Json::number(MAX_BATCH_SCRIPT_BYTES.to_double())), + ) + assert_eq( + limits.get("max_ops"), + Some(Json::number(MAX_BATCH_OPS.to_double())), + ) + assert_eq( + limits.get("max_touched_cells"), + Some(Json::number(MAX_TOTAL_TOUCHED_CELLS.to_double())), + ) + assert_eq( + limits.get("max_style_cells"), + Some(Json::number(MAX_TOTAL_STYLE_CELLS.to_double())), + ) + assert_eq( + limits.get("max_new_styles"), + Some(Json::number(MAX_TOTAL_NEW_STYLES.to_double())), + ) + assert_eq( + limits.get("max_row_column_band"), + Some(Json::number(MAX_ROW_COLUMN_BAND.to_double())), + ) + assert_eq( + limits.get("max_row_column_lines"), + Some(Json::number(MAX_TOTAL_ROW_COLUMN_LINES.to_double())), + ) +} + ///| /// Bidirectional catalog ↔ parser agreement, behaviorally: /// - **parser → catalog**: `required_samples` is the independent list of @@ -175,11 +175,10 @@ test "capabilities payload is field-exact" { /// `accepted_op_names`, failing the membership check. /// - **catalog → parser**: every advertised op parses with its required /// params, and its parsed `name()` round-trips. -/// - **param set exactness**: every catalog param is accepted by the -/// parser (full-param sample parses), and a param NOT in the catalog is -/// rejected — together with the `ParamReader` `declared` guard (which -/// aborts if a parser arm reads an undeclared param), the catalog's param -/// set is exactly the parser's. +/// - **param set exactness**: a param NOT in the catalog is rejected; together +/// with the `ParamReader` `declared` guard (which aborts if a parser arm reads +/// an undeclared param), the catalog cannot omit a parameter consumed by the +/// parser. Mode-specific tests cover conditionally accepted optional params. test "catalog and parser agree in both directions" { let samples = required_samples() let catalog = accepted_op_names() @@ -201,13 +200,6 @@ test "catalog and parser agree in both directions" { let ops = parse_script(@json.parse(one_op_script(name, params))) assert_eq(ops.length(), 1) assert_eq(ops[0].name(), name) - // Every catalog param (required AND optional) is accepted — a full - // sample built from the catalog itself must parse. This exercises the - // `declared` guard for the optional params too (it aborts if the - // parser reads a param the catalog omits). - let full = full_sample(name) - let full_ops = parse_script(@json.parse(one_op_script(name, full))) - assert_eq(full_ops.length(), 1) // Same op with one param outside the catalog must fail unknown-param. // Parse the (valid) JSON outside the `try` so the catch handles only // parse_script's BatchError, not @json.parse's ParseError. diff --git a/batch/helpers.mbt b/batch/helpers.mbt index 75f713a7..676a182d 100644 --- a/batch/helpers.mbt +++ b/batch/helpers.mbt @@ -40,6 +40,28 @@ pub fn build_style( /// Maps a friendly chart-type name to a `ChartType`. Covers the common /// 2-D chart kinds; an unknown name raises a `BatchError` listing the /// choices. +let chart_type_values : Array[String] = [ + "col", "column", "bar", "line", "pie", "area", "scatter", "doughnut", "donut", + "radar", +] + +///| +let sheet_action_values : Array[String] = [ + "rename", "delete", "hide", "show", "very-hide", +] + +///| +let row_action_values : Array[String] = ["hide", "show", "height"] + +///| +let column_action_values : Array[String] = ["hide", "show"] + +///| +let border_style_values : Array[String] = [ + "thin", "medium", "thick", "dashed", "dotted", "double", "hair", +] + +///| pub fn chart_type_of(name : String) -> @xlsx.ChartType raise BatchError { match name { "col" | "column" => Col @@ -57,6 +79,22 @@ pub fn chart_type_of(name : String) -> @xlsx.ChartType raise BatchError { } } +///| +let validation_type_values : Array[String] = [ + "list", "whole", "decimal", "date", "time", "textLength", "custom", +] + +///| +let validation_operator_values : Array[String] = [ + "between", "notBetween", "equal", "notEqual", "greaterThan", "greaterThanOrEqual", + "lessThan", "lessThanOrEqual", +] + +///| +let validation_error_style_values : Array[String] = [ + "stop", "warning", "information", +] + ///| /// Qualifies a cell range with its sheet for a chart series reference. A /// range the caller already qualified (contains `!`) is left as-is; @@ -201,6 +239,78 @@ fn check_inline_list_values(values : Array[String]) -> Unit raise BatchError { } } +///| +/// Validates every data-validation enum and action-dependent parameter without +/// touching a workbook. Batch parsing calls this before source package I/O; +/// application repeats the checks defensively while constructing the engine +/// object. +fn validate_data_validation_contract( + validation_type : String, + operator : String, + formula1 : String, + formula2 : String, + values : Array[String], + source : String, + error_style : String, +) -> Unit raise BatchError { + ignore(validation_error_style_of(error_style)) + match validation_type { + "list" => { + reject_unused_validate_params( + validation_type, + operator~, + formula1~, + formula2~, + ) + if !values.is_empty() { + check_inline_list_values(values) + } else if source == "" { + raise BatchError("validate: type 'list' requires 'values' or 'source'") + } + } + "custom" => { + reject_unused_validate_params( + validation_type, + operator~, + formula1="", + formula2~, + ) + reject_list_only_validate_params(validation_type, values~, source~) + if formula1 == "" { + raise BatchError("validate: type 'custom' requires 'formula1'") + } + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + reject_list_only_validate_params(validation_type, values~, source~) + if operator == "" { + raise BatchError( + "validate: type '\{validation_type}' requires 'operator'", + ) + } + if formula1 == "" { + raise BatchError( + "validate: type '\{validation_type}' requires 'formula1'", + ) + } + ignore(validation_operator_of(operator)) + let two_bound = operator == "between" || operator == "notBetween" + if two_bound && formula2 == "" { + raise BatchError("validate: operator '\{operator}' requires 'formula2'") + } + if !two_bound && formula2 != "" { + raise BatchError( + "validate: operator '\{operator}' does not take 'formula2'", + ) + } + ignore(validation_type_of(validation_type)) + } + other => + raise BatchError( + "validate: unknown type '\{other}' (expected one of: \{validation_type_values.join(", ")})", + ) + } +} + ///| /// The conditional-formatting `type` values the `cf` op supports — a focused /// dashboard subset of the engine's full CF vocabulary. @@ -208,6 +318,14 @@ let cf_supported_types : Array[String] = [ "cell", "formula", "2_color_scale", "3_color_scale", "data_bar", "icon_set", ] +///| +let cf_stop_type_values : Array[String] = [ + "num", "min", "max", "percent", "percentile", "formula", +] + +///| +let cf_bar_direction_values : Array[String] = ["leftToRight", "rightToLeft"] + ///| fn is_supported_cf_type(cf_type : String) -> Bool { cf_supported_types.contains(cf_type) @@ -395,3 +513,173 @@ fn cf_allowed_params(cf_type : String) -> Array[String] { _ => [] } } + +///| +fn validate_conditional_format_contract( + cf_type : String, + criteria : String, + value : String, + min_value : String, + max_value : String, + formula : String, + fill : String, + font_color : String, + bold : Bool?, + italic : Bool?, + min_type : String, + mid_type : String, + max_type : String, + mid_value : String, + min_color : String, + mid_color : String, + max_color : String, + bar_color : String, + bar_solid : Bool?, + bar_direction : String, + bar_border_color : String, + icon_style : String, + reverse_icons : Bool?, + stop_if_true : Bool?, +) -> Unit raise BatchError { + if !is_supported_cf_type(cf_type) { + raise BatchError( + "cf: unknown type '\{cf_type}' (expected one of: \{cf_supported_types.join(", ")})", + ) + } + let present : Array[(String, Bool)] = [ + ("criteria", criteria != ""), + ("value", value != ""), + ("min_value", min_value != ""), + ("max_value", max_value != ""), + ("formula", formula != ""), + ("fill", fill != ""), + ("font_color", font_color != ""), + ("bold", bold is Some(_)), + ("italic", italic is Some(_)), + ("min_type", min_type != ""), + ("mid_type", mid_type != ""), + ("max_type", max_type != ""), + ("mid_value", mid_value != ""), + ("min_color", min_color != ""), + ("mid_color", mid_color != ""), + ("max_color", max_color != ""), + ("bar_color", bar_color != ""), + ("bar_solid", bar_solid is Some(_)), + ("bar_direction", bar_direction != ""), + ("bar_border_color", bar_border_color != ""), + ("icon_style", icon_style != ""), + ("reverse_icons", reverse_icons is Some(_)), + ("stop_if_true", stop_if_true is Some(_)), + ] + let allowed = cf_allowed_params(cf_type) + for entry in present { + let (name, supplied) = entry + if supplied && !allowed.contains(name) { + raise BatchError("cf: type '\{cf_type}' does not take '\{name}'") + } + } + let style_set = fill != "" || + font_color != "" || + bold is Some(true) || + italic is Some(true) + match cf_type { + "cell" => { + if criteria == "" { + raise BatchError("cf: type 'cell' requires 'criteria'") + } + check_cf_cell_criteria(criteria) + if !style_set { + raise BatchError( + "cf: type 'cell' requires a highlight (fill, font_color, bold, or italic)", + ) + } + let normalized = criteria.to_lower() + let between = normalized == "between" || + normalized == "not between" || + normalized == "notbetween" + if between { + if min_value == "" || max_value == "" { + raise BatchError( + "cf: a '\{criteria}' rule requires 'min_value' and 'max_value'", + ) + } + if value != "" { + raise BatchError( + "cf: a '\{criteria}' rule takes 'min_value'/'max_value', not 'value'", + ) + } + } else { + if value == "" { + raise BatchError("cf: type 'cell' requires 'value'") + } + if min_value != "" || max_value != "" { + raise BatchError( + "cf: only a between/notBetween rule takes 'min_value'/'max_value'", + ) + } + } + } + "formula" => { + if formula == "" { + raise BatchError("cf: type 'formula' requires 'formula'") + } + if !style_set { + raise BatchError( + "cf: type 'formula' requires a highlight (fill, font_color, bold, or italic)", + ) + } + } + "2_color_scale" => { + if min_color == "" || max_color == "" { + raise BatchError( + "cf: type '2_color_scale' requires 'min_color' and 'max_color'", + ) + } + check_cf_color("min_color", min_color) + check_cf_color("max_color", max_color) + check_cfvo_type("min_type", min_type) + check_cfvo_type("max_type", max_type) + } + "3_color_scale" => { + if min_color == "" || mid_color == "" || max_color == "" { + raise BatchError( + "cf: type '3_color_scale' requires 'min_color', 'mid_color', and 'max_color'", + ) + } + check_cf_color("min_color", min_color) + check_cf_color("mid_color", mid_color) + check_cf_color("max_color", max_color) + check_cfvo_type("min_type", min_type) + check_cfvo_type("mid_type", mid_type) + check_cfvo_type("max_type", max_type) + } + "data_bar" => { + check_cfvo_type("min_type", min_type) + check_cfvo_type("max_type", max_type) + if bar_color != "" { + check_cf_color("bar_color", bar_color) + } + if bar_direction != "" && !cf_bar_direction_values.contains(bar_direction) { + raise BatchError( + "cf: bar_direction '\{bar_direction}' is not valid (expected leftToRight or rightToLeft)", + ) + } + if bar_border_color != "" { + check_cf_color("bar_border_color", bar_border_color) + } + } + "icon_set" => { + if icon_style == "" { + raise BatchError("cf: type 'icon_set' requires 'icon_style'") + } + check_cf_icon_style(icon_style) + } + _ => () + } + if fill != "" { + check_cf_color("fill", fill) + } + if font_color != "" { + check_cf_color("font_color", font_color) + } +} diff --git a/batch/moon.pkg b/batch/moon.pkg index dda87158..fd184945 100644 --- a/batch/moon.pkg +++ b/batch/moon.pkg @@ -3,15 +3,15 @@ import { "bobzhang/mbtexcel/inspect", "bobzhang/mbtexcel/xlsx", "moonbitlang/core/debug", + "moonbitlang/core/encoding/utf8", + "moonbitlang/core/json", } import { "bobzhang/mbtexcel/xlsx", - "moonbitlang/core/json", } for "test" import { - "moonbitlang/core/json", "moonbitlang/core/random", } for "wbtest" diff --git a/batch/pkg.generated.mbti b/batch/pkg.generated.mbti index c8ed4f7a..9c41ee87 100644 --- a/batch/pkg.generated.mbti +++ b/batch/pkg.generated.mbti @@ -7,10 +7,22 @@ import { } // Values +pub const MAX_BATCH_NUMBER_TOKEN_CHARS : Int = 40 + pub const MAX_BATCH_OPS : Int = 10_000 +pub const MAX_BATCH_SCRIPT_BYTES : Int = 8388608 + +pub const MAX_ROW_COLUMN_BAND : Int = 10_000 + +pub const MAX_TOTAL_NEW_STYLES : Int = 4_096 + +pub const MAX_TOTAL_ROW_COLUMN_LINES : Int64 = 1_000_000 + pub const MAX_TOTAL_STYLE_CELLS : Int64 = 1_000_000 +pub const MAX_TOTAL_TOUCHED_CELLS : Int64 = 1_000_000 + pub const SCHEMA_CAPABILITIES : String = "xlsx.capabilities/1" pub fn apply_op(@xlsx.Workbook, BatchOp) -> Unit raise @@ -23,6 +35,8 @@ pub fn chart_type_of(String) -> @xlsx.ChartType raise BatchError pub fn parse_script(Json) -> Array[BatchOp] raise BatchError +pub fn parse_script_bytes(BytesView) -> BatchPlan raise BatchError + pub fn qualify_range(String, String) -> String // Errors @@ -41,6 +55,19 @@ pub impl Show for BatchError type BatchOp pub fn BatchOp::name(Self) -> String +type BatchPlan +pub fn BatchPlan::apply(Self, @xlsx.Workbook) -> Unit raise BatchError +pub fn BatchPlan::operation_count(Self) -> Int +pub fn BatchPlan::stats(Self) -> BatchPlanStats + +pub struct BatchPlanStats { + operation_count : Int + touched_cells : Int64 + style_cells : Int64 + row_column_lines : Int64 + new_style_records : Int +} + // Type aliases // Traits diff --git a/cmd/xlsx/commands_batch.mbt b/cmd/xlsx/commands_batch.mbt index 769cae2b..96642d7a 100644 --- a/cmd/xlsx/commands_batch.mbt +++ b/cmd/xlsx/commands_batch.mbt @@ -13,12 +13,8 @@ async fn run_batch(m : @argparse.Matches) -> Unit { let file = arg(m, "file") let script_path = arg(m, "script") - let text = @encoding/utf8.decode(@async/fs.read_file(script_path).binary()) - check_number_token_lengths(script_path, text) - let document = @json.parse(text) catch { - err => raise CliError("error: \{script_path}: \{err}") - } - let ops = @batch.parse_script(document) catch { + let script = read_batch_script(script_path) + let plan = @batch.parse_script_bytes(script) catch { BatchError(message) => raise CliError("error: " + message) } // Resolve a symlinked workbook to its target up front: the same resolved @@ -26,61 +22,82 @@ async fn run_batch(m : @argparse.Matches) -> Unit { // save clobber a different file than the one that was read. let target = resolve_save_target(file) let workbook = @lib.open_file(target) - for index, op in ops { - @batch.apply_op(workbook, op) catch { - err => - raise CliError( - "error: op \{index} (\{op.name()}): \{strip_error_prefix(err)}; \{file} not modified", - ) - } + plan.apply(workbook) catch { + BatchError(message) => + raise CliError("error: \{message}; \{file} not modified") } if flag(m, "dry-run") { - println("dry-run ok: \{ops.length()} op(s); \{file} not modified") - } else if ops.length() == 0 { + println("dry-run ok: \{plan.operation_count()} op(s); \{file} not modified") + } else if plan.operation_count() == 0 { // A zero-op script is a valid no-op: nothing to save, file untouched. println("applied 0 op(s) to \{file}") } else { write_file_replacing(target, @lib.write(workbook)) - println("applied \{ops.length()} op(s) to \{file}") + println("applied \{plan.operation_count()} op(s) to \{file}") } } ///| -/// Rejects absurdly long numeric literals (outside strings) before the -/// JSON parser sees them: pathological forms such as an 800-digit -/// mantissa can lose their spelling in parsing and round incorrectly (a -/// known upstream gap), and no real script needs a 40+ character number. -fn check_number_token_lengths( - script_path : String, - text : String, -) -> Unit raise CliError { - let mut in_string = false - let mut escaped = false - let mut run = 0 - for i in 0.. 40 { - raise CliError( - "error: \{script_path}: numeric literal longer than 40 characters", - ) - } - } else { - run = 0 +/// Reads a regular script file through one pinned handle and rejects its size +/// before allocation. A size change during the read fails instead of silently +/// applying a stale prefix. +async fn read_batch_script(path : String) -> Bytes { + let kind = @async/fs.kind(path) catch { + error if @async.is_being_cancelled() => raise error + _ => raise CliError("error: could not inspect batch script \{path}") + } + if !(kind is Regular) { + raise CliError("error: batch script is not a regular file: \{path}") + } + let file = @async/fs.open( + path, + mode=ReadOnly, + create_mode=OpenExisting, + sync=NoSync, + ) catch { + error if @async.is_being_cancelled() => raise error + _ => raise CliError("error: could not open batch script \{path}") + } + defer file.close() + if !(file.kind() is Regular) { + raise CliError("error: batch script is not a regular file: \{path}") + } + let size = file.size() catch { + error if @async.is_being_cancelled() => raise error + _ => raise CliError("error: could not size batch script \{path}") + } + if size < 0L || size > @batch.MAX_BATCH_SCRIPT_BYTES.to_int64() { + raise CliError( + "error: batch script exceeds the \{@batch.MAX_BATCH_SCRIPT_BYTES}-byte limit", + ) + } + let length = size.to_int() + let buffer = FixedArray::make(length, b'\x00') + let mut offset = 0 + while offset < length { + let count = file.read_at( + buffer, + position=offset.to_int64(), + offset~, + len=length - offset, + ) catch { + error if @async.is_being_cancelled() => raise error + _ => raise CliError("error: could not read batch script \{path}") + } + if count <= 0 || count > length - offset { + raise CliError("error: could not read batch script \{path}") } + offset = offset + count + } + let final_size = file.size() catch { + error if @async.is_being_cancelled() => raise error + _ => raise CliError("error: could not recheck batch script \{path}") } + if final_size != size { + raise CliError("error: batch script changed while it was read: \{path}") + } + @async.pause() + buffer.unsafe_reinterpret_as_bytes() } ///| @@ -180,15 +197,3 @@ async fn resolve_save_target(path : String) -> String { async fn resolve_save_target(path : String) -> String noraise { path } - -///| -/// Handler errors that are already CLI-formatted start with "error: "; -/// inside an op-indexed message that prefix would read twice. -fn strip_error_prefix(err : Error) -> String { - let message = "\{err}" - if message.has_prefix("error: ") { - message["error: ".length():].to_owned() - } else { - message - } -} diff --git a/docs/agent-json-schemas.md b/docs/agent-json-schemas.md index f12a0f5d..5ffd7894 100644 --- a/docs/agent-json-schemas.md +++ b/docs/agent-json-schemas.md @@ -384,10 +384,12 @@ Envelope: rules when this one matches. As with `validate`, the `type` is checked at apply time and a param the chosen type does not use is rejected rather than silently ignored. -- Zero ops is a valid no-op and writes nothing. Scripts are capped at - 10,000 ops, style ranges at 1,000,000 expanded cells per script in - aggregate, and numeric literals at 40 characters (pathologically long - literals can round incorrectly upstream). +- Zero ops is a valid no-op and writes nothing. Encoded scripts are capped at + 8 MiB before UTF-8 decoding, 10,000 ops, 1,000,000 direct or expanded cell + mutations, 1,000,000 expanded style cells, 4,096 materialized style or + differential-style records, and 1,000,000 row/column lines across bounded + width/hide/show/height operations. Numeric literals are capped at 40 characters + (pathologically long literals can round incorrectly upstream). - `--dry-run` parses and applies in memory but never writes. - The save resolves a symlinked workbook to its target first (native), writes a uniquely-named temp through its exclusive handle, and renames @@ -396,18 +398,25 @@ Envelope: the original (Windows follows directory ACLs); chmod afterwards for group access. -### Forward-compatibility & `xlsx.capabilities/1` (`xlsx capabilities`) +### Capability negotiation & `xlsx.capabilities/1` (`xlsx capabilities`) -The `xlsx.batch/1` *script* contract is backward-compatible: a script that -was valid stays valid on newer builds. It is **not** forward-compatible — -a stricter older CLI rejects an op or param it doesn't know. So an agent -targeting an unknown build should first query what that build supports: +Before a stable release, `xlsx.batch/1` does not promise backward or forward +compatibility. An agent targeting an unknown build must query the executable +capability catalog instead of assuming an operation, parameter, or limit: ```json { "schema": "xlsx.capabilities/1", "batch_schema": "xlsx.batch/1", - "limits": { "max_ops": 10000, "max_style_cells": 1000000 }, + "limits": { + "max_script_bytes": 8388608, + "max_ops": 10000, + "max_touched_cells": 1000000, + "max_style_cells": 1000000, + "max_new_styles": 4096, + "max_row_column_band": 10000, + "max_row_column_lines": 1000000 + }, "ops": [ { "op": "set", "params": [ { "name": "sheet", "type": "string", "required": true }, @@ -440,7 +449,33 @@ array contains `{code, message}` records. The structured read commands dispatch by validated package content and share canonical `office.selector/1` paths. Full command, selector, limit, and error-code details are in [office-docx-read.md](office-docx-read.md) and -[office-xlsx-read.md](office-xlsx-read.md). +[office-xlsx-read.md](office-xlsx-read.md). Transactional XLSX creation and +batch semantics are specified in +[office-xlsx-mutations.md](office-xlsx-mutations.md). + +### `office.xlsx.create/1` (`office create xlsx OUTPUT --json`) + +| key | type | notes | +| --- | --- | --- | +| `schema` | string | `"office.xlsx.create/1"` | +| `sheet` | string | validated first worksheet name | +| `transaction` | object | `office.transaction/2` validation, preservation, and publication report; creation has null `input`/`original_size` and explicit overwrite-baseline fields | + +### `office.xlsx.batch/1` (`office batch FILE SCRIPT --json`) + +| key | type | notes | +| --- | --- | --- | +| `schema` | string | `"office.xlsx.batch/1"` | +| `stats` | object | exact parsed-plan `operation_count`, `touched_cells`, `style_cells`, `row_column_lines`, and `new_style_records` | +| `transaction` | object | `office.transaction/2` validation, preservation, and publication report | + +A changed plan emits `office.xlsx.full_rewrite`; consumers must use the +transaction preservation report rather than infer retained parts from the +operation list. Zero-op in-place execution reuses the source bytes exactly. +The parsed-plan maxima are upper grammar limits; `office help batch --json` +also advertises the lower transaction materialization ceilings applied to +existing-plus-projected cells, row/column records, decoded XML, markup tokens, +and generated uncompressed archive parts. ### `office.docx.outline/1` (`office outline FILE --json`) diff --git a/docs/office-major-parity.md b/docs/office-major-parity.md index c9ba0b45..8ed51876 100644 --- a/docs/office-major-parity.md +++ b/docs/office-major-parity.md @@ -35,7 +35,7 @@ Codex review at least at `xhigh` effort. | X1: provenance-checked bounded XLSX archive reads | [#160](https://github.com/moonbitlang/office.mbt/issues/160) | Complete | | Coordinate validation hardening | [#138](https://github.com/moonbitlang/office.mbt/issues/138) | Complete | | X2: unified bounded XLSX outline, get, text, and query | [#161](https://github.com/moonbitlang/office.mbt/issues/161) | Complete | -| X3: transactional XLSX create and batch | [#162](https://github.com/moonbitlang/office.mbt/issues/162) | Planned | +| X3: transactional XLSX create and batch | [#162](https://github.com/moonbitlang/office.mbt/issues/162) | Complete | | D3: fresh DOCX create and batch | [#163](https://github.com/moonbitlang/office.mbt/issues/163) | Planned | | D4: preservation-safe DOCX annotation mutations | [#164](https://github.com/moonbitlang/office.mbt/issues/164) | Planned | | V1: cross-format validate and issues | [#165](https://github.com/moonbitlang/office.mbt/issues/165) | Planned | @@ -126,7 +126,7 @@ bounded A4 transaction boundary: D1 is an SDK foundation, not a newly advertised CLI command. No partial command record is added to the A2 registry: its existing raw mutation records already -advertise `office.transaction/1`, whose `preservation` member is the +advertise `office.transaction/2`, whose `preservation` member is the authoritative changed/added/removed/untouched report used by D1. ## D2 DOCX read contract @@ -166,8 +166,9 @@ The XLSX SDK now has one fail-closed policy for every package ingestion path: - opaque `ReadLimits` values jointly bound compressed package bytes, entry count, each inflated entry, aggregate inflation, preserved source records, - each logically decoded OOXML XML part regardless of its filename suffix, and - encrypted-package password-KDF iterations; + each logically decoded OOXML XML part regardless of its filename suffix, + aggregate decoded XML, markup tokens, concrete worksheet cells, retained + row/column dimensions, and encrypted-package password-KDF iterations; - byte reads inflate only through `zip.read_limited`, while the public archive-backed path accepts only pristine non-forgeable bounded provenance at least as strict as its declared policy; compatibility reads, constructed @@ -229,6 +230,35 @@ paths, with Cram coverage for the public schema, canonical selectors, pagination, query predicates, and cross-format mismatch failures. See [office-xlsx-read.md](office-xlsx-read.md) for the complete contract. +## X3 XLSX creation and batch contract + +The unified CLI now exposes `office create xlsx` and `office batch` on one +strict spreadsheet mutation engine: + +- fresh workbooks and updates both serialize under the Office transaction's + candidate-package and live-materialization ceilings, pass portable OPC plus + bounded XLSX validation, and publish atomically through async filesystem I/O; +- creation is no-replace by default, supports explicit overwrite and dry-run, + and reports a null source plus explicit overwrite-baseline evidence in + `office.transaction/2`; +- encoded `xlsx.batch/1` scripts are bounded before UTF-8 and JSON parsing, + retain one opaque plan with operation/resource statistics, and normalize + application failures with the exact zero-based operation index and name; +- operation count, touched cells, expanded style cells, materialized style + records, row/column work, decoded XML, parser objects, generated archive + bytes, package bytes, and output bytes all have explicit ceilings; +- a zero-op in-place plan reuses the exact input bytes, while a changed plan + declares its full-rewrite behavior and exposes authoritative part-level + preservation evidence; and +- native, Wasm, Cram, cancellation/fault, and Microsoft OpenXML SDK tests cover + creation, mutation, dry-run, no-replace/overwrite, and failure cleanup. + +The legacy `xlsx batch` command now delegates parsing and ordered application +to the same `BatchPlan`; its older command-owned atomic publisher remains at +the standalone module boundary, avoiding a dependency cycle with the unified +Office module. See [office-xlsx-mutations.md](office-xlsx-mutations.md) for the +complete command, schema, resource, and SDK contract. + ## Deferred beyond major parity - PowerPoint diff --git a/docs/office-transactions.md b/docs/office-transactions.md index 7cb2633b..5328c4ee 100644 --- a/docs/office-transactions.md +++ b/docs/office-transactions.md @@ -134,6 +134,16 @@ length immediately as a fail-closed contract backstop when a custom callback ignores the supplied allowance. This contract is explicit and does not depend on backend- or optimization-specific object identity. +The XLSX full-rewrite adapter additionally partitions the format-specific +working reserve before it builds a candidate: bounded reads cap decoded XML, +markup tokens, concrete cell records, and retained row/column dimensions; batch +application charges projected cell and row/column growth against those retained +objects; and the generated archive checks every part plus the +aggregate uncompressed payload before retaining it. Only then does +`zip.write_limited` perform its allocation-free final package sizing pass. This +prevents a highly compressible workbook from bypassing the live boundary merely +because its final ZIP is small. + Preservation splices receive a second opaque allowance carved out of the already charged 64 MiB working reserve. At most one eighth of that reserve, capped at 8 MiB, may be retained across caller replacements, newly added part @@ -198,7 +208,7 @@ cannot be called with an oversized, unbounded, or caller-modified snapshot. ## Validation hooks The portable archive-format gate is mandatory and recorded as -`office-portable-opc` in `office.transaction/1`. A custom bounded identifier is +`office-portable-opc` in `office.transaction/2`. A custom bounded identifier is additive: it runs first so format-specific limits can fail before generic Office parsing, then portable OPC validation runs independently, both formats must agree, and both successful gates are recorded. Callers may also register named, @@ -255,12 +265,15 @@ that even container bytes were retained. ## Agent output -Successful reports use schema `office.transaction/1` inside the shared +Successful reports use schema `office.transaction/2` inside the shared `office.output/1` envelope. They include: -- format, input, output, and destination mode; +- format, nullable input, output, and destination mode (`in-place`, `output`, + or source-free `create`); - dry-run, changed, and committed flags; -- original and candidate sizes; +- nullable original size and the candidate size; +- whether a commit replaced a separately observed destination and that + overwrite baseline's pre-validation size; - validation summaries; - preservation changes and counts; - structured portability or durability warnings. diff --git a/docs/office-xlsx-mutations.md b/docs/office-xlsx-mutations.md new file mode 100644 index 00000000..1cc8b530 --- /dev/null +++ b/docs/office-xlsx-mutations.md @@ -0,0 +1,117 @@ +# Transactional XLSX creation and batch mutation + +The canonical `office` facade exposes fresh workbook creation and strict +multi-operation XLSX mutation without introducing a second spreadsheet engine. +Both commands use `moonbitlang/async` filesystem operations and the shared +Office transaction boundary; no C shim or process-local lock is involved. + +## Create + +```text +office create xlsx OUTPUT [--sheet NAME] [--dry-run] [--overwrite] [--json] +``` + +Creation builds a workbook with exactly one worksheet (`Sheet1` by default), +serializes it under the transaction candidate-package ceiling, validates the +portable OPC structure and a complete bounded XLSX parse, then atomically +publishes it. The destination is create-new by default. `--overwrite` is the +only way to replace an existing regular file, and replacement happens only +after all validation succeeds. `--dry-run` performs the same construction and +validation without publishing. + +JSON success data uses `office.xlsx.create/1`: + +```json +{ + "schema": "office.xlsx.create/1", + "sheet": "Data", + "transaction": { "schema": "office.transaction/2" } +} +``` + +## Batch + +```text +office batch FILE SCRIPT [--out FILE] [--dry-run] [--overwrite] [--json] +``` + +`SCRIPT` is a strict UTF-8 `xlsx.batch/1` document. The parser rejects unknown +keys, operations, parameters, types, and enum values. It retains an opaque plan +with resource accounting, and the transaction applies that exact plan in order +to one bounded workbook snapshot. A failed parse, operation, serialization, +validation, cancellation, or publication leaves the input and requested +destination untouched and attempts cancellation-protected cleanup of every +transaction-owned temporary file. A cleanup failure is surfaced as +`office.transaction.cleanup_failed` because an owned temporary artifact may +require operator attention. + +Without `--out`, a successful changed workbook replaces the resolved input +atomically. With `--out`, publication is create-new unless `--overwrite` is +explicit. `--overwrite` without `--out` is rejected. A zero-operation in-place +script reuses the source bytes exactly. A changed plan performs a full workbook +serialization and emits `office.xlsx.full_rewrite`; the embedded transaction +preservation report is authoritative for changed, added, removed, and unchanged +part payloads. + +JSON success data uses `office.xlsx.batch/1`: + +```json +{ + "schema": "office.xlsx.batch/1", + "stats": { + "operation_count": 3, + "touched_cells": 4, + "style_cells": 2, + "row_column_lines": 0, + "new_style_records": 1 + }, + "transaction": { "schema": "office.transaction/2" } +} +``` + +## Resource boundaries + +The encoded script is limited before UTF-8 decoding or JSON parsing. The +current `xlsx.batch/1` ceilings are: + +- 8 MiB of encoded script input; +- 10,000 operations; +- 40 characters per JSON numeric token outside strings; +- 1,000,000 direct or expanded cell mutations; +- 1,000,000 expanded style cells; +- 4,096 style or differential-style records that the plan will materialize; + style-free color-scale, data-bar, and icon-set rules do not consume this + counter; and +- 1,000,000 row/column lines across bounded width, hide, show, and height + operations. + +Those parser ceilings describe scripts accepted by `xlsx.batch/1`. The Office +transaction applies a stricter live-materialization policy before mutation: at +most 32,768 existing-plus-projected concrete cells, 32,768 +existing-plus-projected row/column records, 16 MiB of decoded source XML, and +262,144 XML markup tokens. Candidate +construction retains at most 12 MiB for one generated part and 24 MiB across +the uncompressed archive before ZIP's storage-free package sizing pass. These +limits partition the transaction's fixed working reserve; exceeding one returns +`office.transaction.resource_limit_exceeded` before publication. + +Individual coordinates, ranges, row/column bands, package bytes, entries, +inflation, validation findings, paths, and diagnostics have additional +lower-level ceilings. Agents should inspect both `xlsx capabilities` for the +script grammar and `office help batch --json` for the transaction constraints; +those records are the executable sources of truth. + +## SDK API + +`bobzhang/office/xlsx` exposes the same boundaries to MoonBit callers: + +- `parse_batch(BytesView)` returns an opaque bounded plan; +- `transact_batch(TransactionOptions, BatchPlan)` applies and publishes it; and +- `create_workbook(CreateTransactionOptions, String)` creates one-sheet XLSX. + +All results carry `office.transaction/2` validation, publication, warning, and +preservation evidence. Creation reports `mode=create`, a null input and +original size, and the observed size of a committed overwrite baseline when +one existed. Native and Wasm tests exercise the same async code; +native acceptance additionally validates created and batch-mutated fixtures +with Microsoft's DocumentFormat.OpenXml SDK. diff --git a/office/README.mbt.md b/office/README.mbt.md index b4e4fe39..30ed4918 100644 --- a/office/README.mbt.md +++ b/office/README.mbt.md @@ -29,6 +29,8 @@ office help xlsx office help all --json office help all --jsonl office identify report.docx --json +office create xlsx report.xlsx --sheet Data --json +office batch report.xlsx changes.json --out revised.xlsx --json office raw list report.docx --json office raw read report.docx /document --json ``` @@ -40,6 +42,11 @@ the raw record describes every `list`, `read`, `replace`, and `edit` input, output, constraint, and output mode. PowerPoint and MCP are intentionally absent. +`bobzhang/office/xlsx` provides the bounded mutation SDK behind the canonical +creation and batch commands. It retains strict `xlsx.batch/1` resource +accounting, validates complete candidates, and publishes through the shared +async transaction boundary. See `../docs/office-xlsx-mutations.md`. + The `bobzhang/office/docx` package provides the preservation-safe SDK layer for editing existing DOCX files. Its async `transact_docx` entry point composes the A4 bounded read and atomic publisher with exact source-pinned byte-splice plans, diff --git a/office/capabilities.mbt b/office/capabilities.mbt index 3357f8d0..44115d9a 100644 --- a/office/capabilities.mbt +++ b/office/capabilities.mbt @@ -18,7 +18,7 @@ pub const SCHEMA_RAW_CHANGE : String = "office.raw.change/1" pub const SCHEMA_RAW_RESULT : String = "office.raw.result/1" ///| -pub const SCHEMA_TRANSACTION : String = "office.transaction/1" +pub const SCHEMA_TRANSACTION : String = "office.transaction/2" ///| pub const SCHEMA_DOCX_OUTLINE : String = "office.docx.outline/1" @@ -44,6 +44,12 @@ pub const SCHEMA_XLSX_TEXT : String = "office.xlsx.text/1" ///| pub const SCHEMA_XLSX_QUERY : String = "office.xlsx.query/1" +///| +pub const SCHEMA_XLSX_CREATE_RESULT : String = "office.xlsx.create/1" + +///| +pub const SCHEMA_XLSX_BATCH_RESULT : String = "office.xlsx.batch/1" + ///| /// One declared input or output field in the Office capability registry. pub(all) struct CapabilityField { @@ -606,6 +612,128 @@ pub fn capability_commands() -> Array[CapabilityCommand] { ), ], }, + { + name: "create", + summary: "Create a validated Office document atomically", + usage: "office create xlsx OUTPUT [--sheet NAME] [--dry-run] [--overwrite] [--json]", + formats: ["xlsx"], + aliases: [], + inputs: [ + capability_field( + "format", "literal(xlsx)", true, "document format subcommand", + ), + ], + outputs: [], + output_modes: ["human", "json"], + variants: [ + { + name: "xlsx", + usage: "office create xlsx OUTPUT [--sheet NAME] [--dry-run] [--overwrite] [--json]", + result_schema: SCHEMA_XLSX_CREATE_RESULT, + inputs: [ + capability_field("output", "path", true, "new .xlsx destination"), + capability_field( + "sheet", "xlsx-sheet-name", false, "first worksheet name; default Sheet1", + ), + capability_field( + "dry-run", "boolean", false, "validate without publishing", + ), + capability_field( + "overwrite", "boolean", false, "atomically replace an existing regular-file destination", + ), + capability_field( + "json", "boolean", false, "emit office.output/1 JSON", + ), + ], + outputs: [ + capability_field( + "sheet", "xlsx-sheet-name", true, "created worksheet name", + ), + capability_field( + "transaction", + SCHEMA_TRANSACTION, + true, + "validation, preservation, and publication report", + ), + ], + constraints: [ + "output-extension=.xlsx", + "create-new-by-default", + "transactional-publication", + "bounded-candidate-package", + "candidate-max-entry-bytes=\{XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES}", + "candidate-max-uncompressed-bytes=\{XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES}", + ], + actions: [], + output_modes: ["human", "json"], + }, + ], + }, + { + name: "batch", + summary: "Apply one strict XLSX operation script transactionally", + usage: "office batch FILE SCRIPT [--out FILE] [--dry-run] [--overwrite] [--json]", + formats: ["xlsx"], + aliases: [], + inputs: [], + outputs: [], + output_modes: ["human", "json"], + variants: [ + { + name: "xlsx", + usage: "office batch FILE SCRIPT [--out FILE] [--dry-run] [--overwrite] [--json]", + result_schema: SCHEMA_XLSX_BATCH_RESULT, + inputs: [ + capability_field("file", "path", true, "existing .xlsx package"), + capability_field( + "script", "xlsx.batch/1-file", true, "strict bounded UTF-8 JSON operation script", + ), + capability_field( + "out", "path", false, "separate .xlsx publication destination", + ), + capability_field( + "dry-run", "boolean", false, "validate without publishing", + ), + capability_field( + "overwrite", "boolean", false, "replace an existing separate destination", + ), + capability_field( + "json", "boolean", false, "emit office.output/1 JSON", + ), + ], + outputs: [ + capability_field( + "stats", "object{operation_count,touched_cells,style_cells,row_column_lines,new_style_records}", + true, "bounded parsed-plan resource accounting", + ), + capability_field( + "transaction", + SCHEMA_TRANSACTION, + true, + "validation, preservation, and publication report", + ), + ], + constraints: [ + "schema=xlsx.batch/1", + "overwrite-requires(out)", + "out-extension-must-match-input-format", + "transactional-publication", + "full-workbook-rewrite-on-change", + "zero-op-reuses-original", + "transaction-max-materialized-cells=\{XLSX_TRANSACTION_MAX_MATERIALIZED_CELLS}", + "transaction-max-row-column-lines=\{XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES}", + "read-max-decoded-xml-bytes=\{XLSX_TRANSACTION_MAX_DECODED_XML_BYTES}", + "read-max-markup-tokens=\{XLSX_TRANSACTION_MAX_XML_MARKUP_TOKENS}", + "read-max-materialized-row-column-dimensions=\{XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES}", + "read-max-row-column-dimension-work=\{XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES}", + "candidate-max-entry-bytes=\{XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES}", + "candidate-max-uncompressed-bytes=\{XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES}", + ], + actions: [], + output_modes: ["human", "json"], + }, + ], + }, { name: "raw", summary: "Inventory, read, and atomically edit validated OOXML parts", diff --git a/office/capabilities_test.mbt b/office/capabilities_test.mbt index 7415e4bf..45c456bc 100644 --- a/office/capabilities_test.mbt +++ b/office/capabilities_test.mbt @@ -25,7 +25,7 @@ test "capability registry advertises only implemented commands" { inspect( names.join(","), content=( - #|help,identify,outline,get,text,query,raw + #|help,identify,outline,get,text,query,create,batch,raw ), ) guard commands[0] is { name: "help", usage, inputs: [query, ..], .. } else { @@ -72,7 +72,7 @@ test "raw capability variants describe every accepted option and conflict" { "change,transaction", ) assert_eq(raw.variants[index].outputs[0].type_name, "office.raw.change/1") - assert_eq(raw.variants[index].outputs[1].type_name, "office.transaction/1") + assert_eq(raw.variants[index].outputs[1].type_name, "office.transaction/2") assert_true( raw.variants[index].constraints.contains( "out-extension-must-match-input-format", @@ -283,13 +283,13 @@ test "capability registry format filters remain truthful" { inspect( records, content=( - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"format","name":"docx","aliases":["word"],"description":"WordprocessingML documents","selector":{"schema":"office.selector/1","root":"/docx","status":"read-resolved","examples":["/docx/body/p[1]/r[2]","/docx/comments/comment[id=\"7\"]"],"description":"bounded canonical resolution for outline, get, text, and declared query predicates"}} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"identify","summary":"Identify a structurally valid XLSX or DOCX package","usage":"office identify [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"path to an XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit one office.output/1 JSON document"}],"outputs":[{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"the structurally verified package format"}],"output_modes":["human","json"],"variants":[]} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"outline","summary":"Summarize bounded XLSX or DOCX structure using canonical selectors","usage":"office outline FILE [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.outline/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: number of elements in the bounded projection"},{"name":"counts","type":"object","required":false,"description":"DOCX only: deterministic structural counts across every story"},{"name":"stories","type":"array","required":false,"description":"DOCX only: story roots"},{"name":"headings","type":"array","required":false,"description":"DOCX only: bounded heading previews"},{"name":"styles_in_use","type":"array","required":false,"description":"DOCX only: deduplicated referenced styles"},{"name":"images","type":"array","required":false,"description":"DOCX only: image metadata"},{"name":"sections","type":"array","required":false,"description":"DOCX only: section boundaries and header/footer references"},{"name":"diagnostics","type":"array","required":false,"description":"DOCX only: reader and source diagnostics"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office outline FILE","result_schema":"office.docx.outline/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.outline/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: number of elements in the bounded projection"},{"name":"counts","type":"object","required":false,"description":"DOCX only: deterministic structural counts across every story"},{"name":"stories","type":"array","required":false,"description":"DOCX only: story roots"},{"name":"headings","type":"array","required":false,"description":"DOCX only: bounded heading previews"},{"name":"styles_in_use","type":"array","required":false,"description":"DOCX only: deduplicated referenced styles"},{"name":"images","type":"array","required":false,"description":"DOCX only: image metadata"},{"name":"sections","type":"array","required":false,"description":"DOCX only: section boundaries and header/footer references"},{"name":"diagnostics","type":"array","required":false,"description":"DOCX only: reader and source diagnostics"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"get","summary":"Resolve one canonical XLSX or DOCX selector","usage":"office get FILE SELECTOR [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"selector","type":"office.selector/1","required":true,"description":"canonical selector matching the validated package format"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.element/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"path","type":"office.selector/1","required":true,"description":"resolved canonical path"},{"name":"kind","type":"string","required":true,"description":"resolved workbook, sheet, coordinate, story, annotation, or element kind"},{"name":"role","type":"enum(story-root|annotation-collection|annotation-item|element)","required":false,"description":"DOCX only: projection role"},{"name":"stability","type":"enum(stable|snapshot-relative)","required":true,"description":"selector stability classification"},{"name":"source","type":"object","required":false,"description":"DOCX only: physical story source metadata"},{"name":"parent","type":"office.selector/1","required":false,"description":"canonical parent path; absent for projection roots"},{"name":"id","type":"string","required":false,"description":"annotation id when the resolved item carries one"},{"name":"children","type":"array","required":false,"description":"DOCX only: addressable direct children"},{"name":"properties","type":"object","required":false,"description":"DOCX only: declared formatting and element summary"},{"name":"metadata","type":"object","required":false,"description":"DOCX only: role-specific metadata"},{"name":"text","type":"string","required":false,"description":"DOCX only: bounded raw text projection"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office get FILE /docx/...","result_schema":"office.docx.element/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"selector","type":"office.selector/1","required":true,"description":"canonical selector matching the validated package format"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.element/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"path","type":"office.selector/1","required":true,"description":"resolved canonical path"},{"name":"kind","type":"string","required":true,"description":"resolved workbook, sheet, coordinate, story, annotation, or element kind"},{"name":"role","type":"enum(story-root|annotation-collection|annotation-item|element)","required":false,"description":"DOCX only: projection role"},{"name":"stability","type":"enum(stable|snapshot-relative)","required":true,"description":"selector stability classification"},{"name":"source","type":"object","required":false,"description":"DOCX only: physical story source metadata"},{"name":"parent","type":"office.selector/1","required":false,"description":"canonical parent path; absent for projection roots"},{"name":"id","type":"string","required":false,"description":"annotation id when the resolved item carries one"},{"name":"children","type":"array","required":false,"description":"DOCX only: addressable direct children"},{"name":"properties","type":"object","required":false,"description":"DOCX only: declared formatting and element summary"},{"name":"metadata","type":"object","required":false,"description":"DOCX only: role-specific metadata"},{"name":"text","type":"string","required":false,"description":"DOCX only: bounded raw text projection"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"text","summary":"Extract bounded path-tagged XLSX cell or DOCX paragraph text","usage":"office text FILE [--under SELECTOR] [--offset N] [--limit N] [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based matching paragraph or cell offset; default 0"},{"name":"limit","type":"integer(0..10000)","required":false,"description":"maximum returned paragraphs or cells; default 2000"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.text/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"entries","type":"array(object{path,text,stability})","required":true,"description":"paragraphs in document order or cells in sheet/row-major order"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan matching paragraph or cell count"},{"name":"returned","type":"integer","required":true,"description":"number of returned entries"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office text FILE [--under /docx/...]","result_schema":"office.docx.text/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based matching paragraph or cell offset; default 0"},{"name":"limit","type":"integer(0..10000)","required":false,"description":"maximum returned paragraphs or cells; default 2000"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.text/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"entries","type":"array(object{path,text,stability})","required":true,"description":"paragraphs in document order or cells in sheet/row-major order"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan matching paragraph or cell count"},{"name":"returned","type":"integer","required":true,"description":"number of returned entries"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"query","summary":"Run bounded deterministic predicates over XLSX cells or DOCX elements","usage":"office query FILE [CELL_SELECTOR] [--under SELECTOR] [--kind KIND] [--text TEXT] [--id ID] [--property NAME=VALUE]... [--ignore-case] [--offset N] [--limit N] [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"kind","type":"enum(body|header|footer|footnotes|endnotes|comments|note|comment|p|r|tbl|tr|tc|hyperlink|image)","required":false,"description":"DOCX only: exact kind; documented aliases normalize before matching"},{"name":"text","type":"literal-string(1..1048576 chars)","required":false,"description":"DOCX only: literal substring predicate; regular expressions are not accepted"},{"name":"id","type":"string(1..1048576 chars)","required":false,"description":"DOCX only: exact annotation id predicate"},{"name":"property","type":"array(NAME=VALUE)","required":false,"description":"DOCX only: up to 16 exact declared-property predicates"},{"name":"ignore-case","type":"boolean","required":false,"description":"DOCX only: locale-independent Unicode simple-case --text matching"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based match offset; default 0"},{"name":"limit","type":"integer(0..1000)","required":false,"description":"maximum returned matches; default 100"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.query/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"filters","type":"object","required":false,"description":"DOCX only: normalized predicates applied by this query"},{"name":"matches","type":"array","required":true,"description":"deterministic document-order or sheet/row-major match records"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan match count"},{"name":"returned","type":"integer","required":true,"description":"number of returned matches"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office query FILE [DOCX predicates]","result_schema":"office.docx.query/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"kind","type":"enum(body|header|footer|footnotes|endnotes|comments|note|comment|p|r|tbl|tr|tc|hyperlink|image)","required":false,"description":"DOCX only: exact kind; documented aliases normalize before matching"},{"name":"text","type":"literal-string(1..1048576 chars)","required":false,"description":"DOCX only: literal substring predicate; regular expressions are not accepted"},{"name":"id","type":"string(1..1048576 chars)","required":false,"description":"DOCX only: exact annotation id predicate"},{"name":"property","type":"array(NAME=VALUE)","required":false,"description":"DOCX only: up to 16 exact declared-property predicates"},{"name":"ignore-case","type":"boolean","required":false,"description":"DOCX only: locale-independent Unicode simple-case --text matching"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based match offset; default 0"},{"name":"limit","type":"integer(0..1000)","required":false,"description":"maximum returned matches; default 100"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.query/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"filters","type":"object","required":false,"description":"DOCX only: normalized predicates applied by this query"},{"name":"matches","type":"array","required":true,"description":"deterministic document-order or sheet/row-major match records"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan match count"},{"name":"returned","type":"integer","required":true,"description":"number of returned matches"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} - #|{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"raw","summary":"Inventory, read, and atomically edit validated OOXML parts","usage":"office raw ...","formats":["docx"],"aliases":[],"inputs":[{"name":"operation","type":"enum(list|read|replace|edit)","required":true,"description":"bounded raw OOXML operation"}],"outputs":[],"output_modes":["human","json","base64","file"],"variants":[{"name":"list","usage":"office raw list FILE [--json]","result_schema":"office.raw.inventory/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"format","type":"literal(docx)","required":true,"description":"structurally verified package format"},{"name":"part_count","type":"integer","required":true,"description":"number of inventoried package parts"},{"name":"parts","type":"array(object{name:path,content_type:string,kind:enum(xml|binary),size:integer,aliases:array(string)})","required":true,"description":"bounded canonical part inventory records"}],"constraints":[],"actions":[],"output_modes":["human","json"]},{"name":"read","usage":"office raw read FILE PART [--json] [--base64 | --output FILE]","result_schema":"office.raw.part/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"/name when unambiguous, alias:/name, or part:/name"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"},{"name":"base64","type":"boolean","required":false,"description":"emit exact payload as base64"},{"name":"output","type":"path","required":false,"description":"create a file with the exact payload"}],"outputs":[{"name":"format","type":"literal(docx)","required":true,"description":"structurally verified package format"},{"name":"part","type":"object{name:path,content_type:string,kind:enum(xml|binary),size:integer,aliases:array(string)}","required":true,"description":"resolved package-part metadata"},{"name":"encoding","type":"enum(xml|base64|binary)","required":true,"description":"selected payload representation"},{"name":"content","type":"string","required":false,"description":"decoded XML text or base64 payload"},{"name":"output","type":"path","required":false,"description":"created payload destination"}],"constraints":["mutually-exclusive(base64,output)","binary-requires(base64|output)","output-create-mode(create-new)"],"actions":[],"output_modes":["human","json","base64","file"]},{"name":"replace","usage":"office raw replace FILE PART (--xml XML | --xml-file FILE) [--out FILE] [--dry-run] [--overwrite] [--json]","result_schema":"office.raw.result/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"existing XML part selector"},{"name":"xml","type":"utf8-xml","required":false,"description":"complete replacement XML document"},{"name":"xml-file","type":"path","required":false,"description":"bounded UTF-8 replacement XML file"},{"name":"out","type":"path","required":false,"description":"separate destination with the same .docx or .xlsx extension as the input"},{"name":"dry-run","type":"boolean","required":false,"description":"validate without publishing"},{"name":"overwrite","type":"boolean","required":false,"description":"replace an existing separate destination"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"change","type":"office.raw.change/1","required":true,"description":"validated raw mutation summary"},{"name":"transaction","type":"office.transaction/1","required":true,"description":"transaction validation and publication report"}],"constraints":["exactly-one(xml,xml-file)","overwrite-requires(out)","out-extension-must-match-input-format","transactional-publication"],"actions":[],"output_modes":["human","json"]},{"name":"edit","usage":"office raw edit FILE PART --path PATH --action ACTION [action arguments] [--namespace PREFIX=URI]... [--all] [--out FILE] [--dry-run] [--overwrite] [--json]","result_schema":"office.raw.result/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"existing XML part selector"},{"name":"path","type":"office.raw.path/1","required":true,"description":"bounded namespace-aware element selector"},{"name":"action","type":"enum(append|prepend|insert-before|insert-after|replace|remove|set-attribute)","required":true,"description":"bounded edit action"},{"name":"xml","type":"utf8-xml-element","required":false,"description":"one self-contained XML element"},{"name":"xml-file","type":"path","required":false,"description":"bounded UTF-8 XML element file"},{"name":"attribute","type":"qname","required":false,"description":"set-attribute target name"},{"name":"value","type":"string","required":false,"description":"set-attribute exact decoded value"},{"name":"namespace","type":"array(PREFIX=URI)","required":false,"description":"repeatable selector namespace overrides"},{"name":"all","type":"boolean","required":false,"description":"allow multiple bounded matches"},{"name":"out","type":"path","required":false,"description":"separate destination with the same .docx or .xlsx extension as the input"},{"name":"dry-run","type":"boolean","required":false,"description":"validate without publishing"},{"name":"overwrite","type":"boolean","required":false,"description":"replace an existing separate destination"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"change","type":"office.raw.change/1","required":true,"description":"validated raw mutation summary"},{"name":"transaction","type":"office.transaction/1","required":true,"description":"transaction validation and publication report"}],"constraints":["mutually-exclusive(xml,xml-file)","element-actions-require(exactly-one(xml,xml-file))","set-attribute-requires(attribute,value)","remove-forbids(xml,xml-file,attribute,value)","overwrite-requires(out)","flag-looking-values-require-attached-syntax","out-extension-must-match-input-format","transactional-publication"],"actions":[{"name":"append","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"prepend","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"insert-before","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"insert-after","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"replace","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"remove","requires":[],"forbids":["xml","xml-file","attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"set-attribute","requires":["attribute","value"],"forbids":["xml","xml-file"],"restrictions":[]}],"output_modes":["human","json"]}]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"format","name":"docx","aliases":["word"],"description":"WordprocessingML documents","selector":{"schema":"office.selector/1","root":"/docx","status":"read-resolved","examples":["/docx/body/p[1]/r[2]","/docx/comments/comment[id=\"7\"]"],"description":"bounded canonical resolution for outline, get, text, and declared query predicates"}} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"identify","summary":"Identify a structurally valid XLSX or DOCX package","usage":"office identify [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"path to an XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit one office.output/1 JSON document"}],"outputs":[{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"the structurally verified package format"}],"output_modes":["human","json"],"variants":[]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"outline","summary":"Summarize bounded XLSX or DOCX structure using canonical selectors","usage":"office outline FILE [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.outline/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: number of elements in the bounded projection"},{"name":"counts","type":"object","required":false,"description":"DOCX only: deterministic structural counts across every story"},{"name":"stories","type":"array","required":false,"description":"DOCX only: story roots"},{"name":"headings","type":"array","required":false,"description":"DOCX only: bounded heading previews"},{"name":"styles_in_use","type":"array","required":false,"description":"DOCX only: deduplicated referenced styles"},{"name":"images","type":"array","required":false,"description":"DOCX only: image metadata"},{"name":"sections","type":"array","required":false,"description":"DOCX only: section boundaries and header/footer references"},{"name":"diagnostics","type":"array","required":false,"description":"DOCX only: reader and source diagnostics"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office outline FILE","result_schema":"office.docx.outline/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.outline/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: number of elements in the bounded projection"},{"name":"counts","type":"object","required":false,"description":"DOCX only: deterministic structural counts across every story"},{"name":"stories","type":"array","required":false,"description":"DOCX only: story roots"},{"name":"headings","type":"array","required":false,"description":"DOCX only: bounded heading previews"},{"name":"styles_in_use","type":"array","required":false,"description":"DOCX only: deduplicated referenced styles"},{"name":"images","type":"array","required":false,"description":"DOCX only: image metadata"},{"name":"sections","type":"array","required":false,"description":"DOCX only: section boundaries and header/footer references"},{"name":"diagnostics","type":"array","required":false,"description":"DOCX only: reader and source diagnostics"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"get","summary":"Resolve one canonical XLSX or DOCX selector","usage":"office get FILE SELECTOR [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"selector","type":"office.selector/1","required":true,"description":"canonical selector matching the validated package format"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.element/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"path","type":"office.selector/1","required":true,"description":"resolved canonical path"},{"name":"kind","type":"string","required":true,"description":"resolved workbook, sheet, coordinate, story, annotation, or element kind"},{"name":"role","type":"enum(story-root|annotation-collection|annotation-item|element)","required":false,"description":"DOCX only: projection role"},{"name":"stability","type":"enum(stable|snapshot-relative)","required":true,"description":"selector stability classification"},{"name":"source","type":"object","required":false,"description":"DOCX only: physical story source metadata"},{"name":"parent","type":"office.selector/1","required":false,"description":"canonical parent path; absent for projection roots"},{"name":"id","type":"string","required":false,"description":"annotation id when the resolved item carries one"},{"name":"children","type":"array","required":false,"description":"DOCX only: addressable direct children"},{"name":"properties","type":"object","required":false,"description":"DOCX only: declared formatting and element summary"},{"name":"metadata","type":"object","required":false,"description":"DOCX only: role-specific metadata"},{"name":"text","type":"string","required":false,"description":"DOCX only: bounded raw text projection"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office get FILE /docx/...","result_schema":"office.docx.element/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"selector","type":"office.selector/1","required":true,"description":"canonical selector matching the validated package format"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.element/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"path","type":"office.selector/1","required":true,"description":"resolved canonical path"},{"name":"kind","type":"string","required":true,"description":"resolved workbook, sheet, coordinate, story, annotation, or element kind"},{"name":"role","type":"enum(story-root|annotation-collection|annotation-item|element)","required":false,"description":"DOCX only: projection role"},{"name":"stability","type":"enum(stable|snapshot-relative)","required":true,"description":"selector stability classification"},{"name":"source","type":"object","required":false,"description":"DOCX only: physical story source metadata"},{"name":"parent","type":"office.selector/1","required":false,"description":"canonical parent path; absent for projection roots"},{"name":"id","type":"string","required":false,"description":"annotation id when the resolved item carries one"},{"name":"children","type":"array","required":false,"description":"DOCX only: addressable direct children"},{"name":"properties","type":"object","required":false,"description":"DOCX only: declared formatting and element summary"},{"name":"metadata","type":"object","required":false,"description":"DOCX only: role-specific metadata"},{"name":"text","type":"string","required":false,"description":"DOCX only: bounded raw text projection"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"text","summary":"Extract bounded path-tagged XLSX cell or DOCX paragraph text","usage":"office text FILE [--under SELECTOR] [--offset N] [--limit N] [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based matching paragraph or cell offset; default 0"},{"name":"limit","type":"integer(0..10000)","required":false,"description":"maximum returned paragraphs or cells; default 2000"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.text/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"entries","type":"array(object{path,text,stability})","required":true,"description":"paragraphs in document order or cells in sheet/row-major order"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan matching paragraph or cell count"},{"name":"returned","type":"integer","required":true,"description":"number of returned entries"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office text FILE [--under /docx/...]","result_schema":"office.docx.text/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based matching paragraph or cell offset; default 0"},{"name":"limit","type":"integer(0..10000)","required":false,"description":"maximum returned paragraphs or cells; default 2000"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.text/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"entries","type":"array(object{path,text,stability})","required":true,"description":"paragraphs in document order or cells in sheet/row-major order"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan matching paragraph or cell count"},{"name":"returned","type":"integer","required":true,"description":"number of returned entries"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"query","summary":"Run bounded deterministic predicates over XLSX cells or DOCX elements","usage":"office query FILE [CELL_SELECTOR] [--under SELECTOR] [--kind KIND] [--text TEXT] [--id ID] [--property NAME=VALUE]... [--ignore-case] [--offset N] [--limit N] [--max-elements N] [--max-output-chars N] [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"kind","type":"enum(body|header|footer|footnotes|endnotes|comments|note|comment|p|r|tbl|tr|tc|hyperlink|image)","required":false,"description":"DOCX only: exact kind; documented aliases normalize before matching"},{"name":"text","type":"literal-string(1..1048576 chars)","required":false,"description":"DOCX only: literal substring predicate; regular expressions are not accepted"},{"name":"id","type":"string(1..1048576 chars)","required":false,"description":"DOCX only: exact annotation id predicate"},{"name":"property","type":"array(NAME=VALUE)","required":false,"description":"DOCX only: up to 16 exact declared-property predicates"},{"name":"ignore-case","type":"boolean","required":false,"description":"DOCX only: locale-independent Unicode simple-case --text matching"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based match offset; default 0"},{"name":"limit","type":"integer(0..1000)","required":false,"description":"maximum returned matches; default 100"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.query/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"filters","type":"object","required":false,"description":"DOCX only: normalized predicates applied by this query"},{"name":"matches","type":"array","required":true,"description":"deterministic document-order or sheet/row-major match records"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan match count"},{"name":"returned","type":"integer","required":true,"description":"number of returned matches"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"output_modes":["human","json"],"variants":[{"name":"docx","usage":"office query FILE [DOCX predicates]","result_schema":"office.docx.query/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"under","type":"office.selector/1","required":false,"description":"optional canonical DOCX subtree or XLSX workbook/sheet/range/cell scope"},{"name":"kind","type":"enum(body|header|footer|footnotes|endnotes|comments|note|comment|p|r|tbl|tr|tc|hyperlink|image)","required":false,"description":"DOCX only: exact kind; documented aliases normalize before matching"},{"name":"text","type":"literal-string(1..1048576 chars)","required":false,"description":"DOCX only: literal substring predicate; regular expressions are not accepted"},{"name":"id","type":"string(1..1048576 chars)","required":false,"description":"DOCX only: exact annotation id predicate"},{"name":"property","type":"array(NAME=VALUE)","required":false,"description":"DOCX only: up to 16 exact declared-property predicates"},{"name":"ignore-case","type":"boolean","required":false,"description":"DOCX only: locale-independent Unicode simple-case --text matching"},{"name":"offset","type":"integer(0..200000)","required":false,"description":"zero-based match offset; default 0"},{"name":"limit","type":"integer(0..1000)","required":false,"description":"maximum returned matches; default 100"},{"name":"max-elements","type":"integer(1..200000)","required":false,"description":"DOCX projection-node or XLSX cell scan ceiling; default 50000; XLSX effective hard ceiling 100000"},{"name":"max-output-chars","type":"integer(1..4194304)","required":false,"description":"successful stdout ceiling including trailing LF; failure envelopes are separate; default 1048576"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"schema","type":"literal(office.docx.query/1)","required":true,"description":"format-specific versioned result schema"},{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"resolved document format"},{"name":"filters","type":"object","required":false,"description":"DOCX only: normalized predicates applied by this query"},{"name":"matches","type":"array","required":true,"description":"deterministic document-order or sheet/row-major match records"},{"name":"matched_total","type":"integer","required":true,"description":"complete bounded-scan match count"},{"name":"returned","type":"integer","required":true,"description":"number of returned matches"},{"name":"truncated","type":"boolean","required":true,"description":"whether pagination omitted later matches"},{"name":"offset","type":"integer","required":true,"description":"applied zero-based match offset"},{"name":"limit","type":"integer","required":true,"description":"applied page-size ceiling"},{"name":"scanned_elements","type":"integer","required":false,"description":"DOCX only: projected element count"},{"name":"under","type":"office.selector/1","required":false,"description":"resolved canonical scope when one was requested"}],"constraints":["format=docx"],"actions":[],"output_modes":["human","json"]}]} + #|{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"raw","summary":"Inventory, read, and atomically edit validated OOXML parts","usage":"office raw ...","formats":["docx"],"aliases":[],"inputs":[{"name":"operation","type":"enum(list|read|replace|edit)","required":true,"description":"bounded raw OOXML operation"}],"outputs":[],"output_modes":["human","json","base64","file"],"variants":[{"name":"list","usage":"office raw list FILE [--json]","result_schema":"office.raw.inventory/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"format","type":"literal(docx)","required":true,"description":"structurally verified package format"},{"name":"part_count","type":"integer","required":true,"description":"number of inventoried package parts"},{"name":"parts","type":"array(object{name:path,content_type:string,kind:enum(xml|binary),size:integer,aliases:array(string)})","required":true,"description":"bounded canonical part inventory records"}],"constraints":[],"actions":[],"output_modes":["human","json"]},{"name":"read","usage":"office raw read FILE PART [--json] [--base64 | --output FILE]","result_schema":"office.raw.part/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"/name when unambiguous, alias:/name, or part:/name"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"},{"name":"base64","type":"boolean","required":false,"description":"emit exact payload as base64"},{"name":"output","type":"path","required":false,"description":"create a file with the exact payload"}],"outputs":[{"name":"format","type":"literal(docx)","required":true,"description":"structurally verified package format"},{"name":"part","type":"object{name:path,content_type:string,kind:enum(xml|binary),size:integer,aliases:array(string)}","required":true,"description":"resolved package-part metadata"},{"name":"encoding","type":"enum(xml|base64|binary)","required":true,"description":"selected payload representation"},{"name":"content","type":"string","required":false,"description":"decoded XML text or base64 payload"},{"name":"output","type":"path","required":false,"description":"created payload destination"}],"constraints":["mutually-exclusive(base64,output)","binary-requires(base64|output)","output-create-mode(create-new)"],"actions":[],"output_modes":["human","json","base64","file"]},{"name":"replace","usage":"office raw replace FILE PART (--xml XML | --xml-file FILE) [--out FILE] [--dry-run] [--overwrite] [--json]","result_schema":"office.raw.result/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"existing XML part selector"},{"name":"xml","type":"utf8-xml","required":false,"description":"complete replacement XML document"},{"name":"xml-file","type":"path","required":false,"description":"bounded UTF-8 replacement XML file"},{"name":"out","type":"path","required":false,"description":"separate destination with the same .docx or .xlsx extension as the input"},{"name":"dry-run","type":"boolean","required":false,"description":"validate without publishing"},{"name":"overwrite","type":"boolean","required":false,"description":"replace an existing separate destination"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"change","type":"office.raw.change/1","required":true,"description":"validated raw mutation summary"},{"name":"transaction","type":"office.transaction/2","required":true,"description":"transaction validation and publication report"}],"constraints":["exactly-one(xml,xml-file)","overwrite-requires(out)","out-extension-must-match-input-format","transactional-publication"],"actions":[],"output_modes":["human","json"]},{"name":"edit","usage":"office raw edit FILE PART --path PATH --action ACTION [action arguments] [--namespace PREFIX=URI]... [--all] [--out FILE] [--dry-run] [--overwrite] [--json]","result_schema":"office.raw.result/1","inputs":[{"name":"file","type":"path","required":true,"description":"existing XLSX or DOCX package"},{"name":"part","type":"part-selector","required":true,"description":"existing XML part selector"},{"name":"path","type":"office.raw.path/1","required":true,"description":"bounded namespace-aware element selector"},{"name":"action","type":"enum(append|prepend|insert-before|insert-after|replace|remove|set-attribute)","required":true,"description":"bounded edit action"},{"name":"xml","type":"utf8-xml-element","required":false,"description":"one self-contained XML element"},{"name":"xml-file","type":"path","required":false,"description":"bounded UTF-8 XML element file"},{"name":"attribute","type":"qname","required":false,"description":"set-attribute target name"},{"name":"value","type":"string","required":false,"description":"set-attribute exact decoded value"},{"name":"namespace","type":"array(PREFIX=URI)","required":false,"description":"repeatable selector namespace overrides"},{"name":"all","type":"boolean","required":false,"description":"allow multiple bounded matches"},{"name":"out","type":"path","required":false,"description":"separate destination with the same .docx or .xlsx extension as the input"},{"name":"dry-run","type":"boolean","required":false,"description":"validate without publishing"},{"name":"overwrite","type":"boolean","required":false,"description":"replace an existing separate destination"},{"name":"json","type":"boolean","required":false,"description":"emit office.output/1 JSON"}],"outputs":[{"name":"change","type":"office.raw.change/1","required":true,"description":"validated raw mutation summary"},{"name":"transaction","type":"office.transaction/2","required":true,"description":"transaction validation and publication report"}],"constraints":["mutually-exclusive(xml,xml-file)","element-actions-require(exactly-one(xml,xml-file))","set-attribute-requires(attribute,value)","remove-forbids(xml,xml-file,attribute,value)","overwrite-requires(out)","flag-looking-values-require-attached-syntax","out-extension-must-match-input-format","transactional-publication"],"actions":[{"name":"append","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"prepend","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"insert-before","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"insert-after","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"replace","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"remove","requires":[],"forbids":["xml","xml-file","attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"set-attribute","requires":["attribute","value"],"forbids":["xml","xml-file"],"restrictions":[]}],"output_modes":["human","json"]}]} ), ) } @@ -299,7 +299,7 @@ test "capability fingerprint and inventory ordering are deterministic" { inspect( @office.capability_fingerprint(), content=( - #|crc32:9e349fa4 + #|crc32:3cca2563 ), ) let first = @office.capabilities_data().stringify() @@ -315,7 +315,7 @@ test "capability operation queries do not leak unrelated records" { inspect( data.stringify(), content=( - #|{"schema":"office.capabilities/2","fingerprint":"crc32:9e349fa4","records":[{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"identify","summary":"Identify a structurally valid XLSX or DOCX package","usage":"office identify [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"path to an XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit one office.output/1 JSON document"}],"outputs":[{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"the structurally verified package format"}],"output_modes":["human","json"],"variants":[]}],"format":"docx","operation":"identify"} + #|{"schema":"office.capabilities/2","fingerprint":"crc32:3cca2563","records":[{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"identify","summary":"Identify a structurally valid XLSX or DOCX package","usage":"office identify [--json]","formats":["docx"],"aliases":[],"inputs":[{"name":"file","type":"path","required":true,"description":"path to an XLSX or DOCX package"},{"name":"json","type":"boolean","required":false,"description":"emit one office.output/1 JSON document"}],"outputs":[{"name":"file","type":"path","required":true,"description":"the input path exactly as supplied"},{"name":"format","type":"literal(docx)","required":true,"description":"the structurally verified package format"}],"output_modes":["human","json"],"variants":[]}],"format":"docx","operation":"identify"} ), ) } diff --git a/office/cmd/office/cram/cli.t b/office/cmd/office/cram/cli.t index ab39dd81..e80cd8d7 100644 --- a/office/cmd/office/cram/cli.t +++ b/office/cmd/office/cram/cli.t @@ -17,7 +17,7 @@ and JSONL inventories without deferred PowerPoint or MCP entries. $ office.exe help | sed -n '1,8p' Office capability registry Schema: office.capabilities/2 - Fingerprint: crc32:9e349fa4 + Fingerprint: crc32:3cca2563 Formats: docx (aliases: word) — WordprocessingML documents xlsx (aliases: excel) — SpreadsheetML workbooks @@ -40,10 +40,10 @@ and JSONL inventories without deferred PowerPoint or MCP entries. {"formats":["xlsx"],"variants":[{"name":"xlsx","result_schema":"office.xlsx.query/1","constraints":["format=xlsx"]}]} $ office.exe help all --json | jq -c '{schema,success,capability_schema:.data.schema,fingerprint:.data.fingerprint,names:[.data.records[].name]}' - {"schema":"office.output/1","success":true,"capability_schema":"office.capabilities/2","fingerprint":"crc32:9e349fa4","names":["docx","xlsx","help","identify","outline","get","text","query","raw"]} + {"schema":"office.output/1","success":true,"capability_schema":"office.capabilities/2","fingerprint":"crc32:3cca2563","names":["docx","xlsx","help","identify","outline","get","text","query","create","batch","raw"]} $ office.exe help all --jsonl | jq -s -c 'map({schema,fingerprint,kind,name})' - [{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"format","name":"docx"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"format","name":"xlsx"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"help"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"identify"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"outline"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"get"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"text"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"query"},{"schema":"office.capability/2","fingerprint":"crc32:9e349fa4","kind":"command","name":"raw"}] + [{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"format","name":"docx"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"format","name":"xlsx"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"help"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"identify"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"outline"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"get"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"text"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"query"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"create"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"batch"},{"schema":"office.capability/2","fingerprint":"crc32:3cca2563","kind":"command","name":"raw"}] The raw command publishes explicit subcommand schemas, including every edit input and its conditional constraints. @@ -57,6 +57,103 @@ input and its conditional constraints. $ office.exe help raw --json | jq -c '[.data.records[0].variants[] | select(.name=="edit") | .actions[] | {name,requires,forbids,restrictions}]' [{"name":"append","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"prepend","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"insert-before","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"insert-after","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"replace","requires":["exactly-one(xml,xml-file)"],"forbids":["attribute","value"],"restrictions":[]},{"name":"remove","requires":[],"forbids":["xml","xml-file","attribute","value"],"restrictions":["path-must-not-select-document-element"]},{"name":"set-attribute","requires":["attribute","value"],"forbids":["xml","xml-file"],"restrictions":[]}] +Fresh XLSX creation and strict batch mutation share the validated transaction +boundary. Creation is no-replace by default; batch updates preserve the input +until parsing, application, serialization, and complete candidate validation +all pass. + + $ office.exe help create --json | jq -c '.data.records[0] | {name,formats,variants:[.variants[]|{name,result_schema,inputs:[.inputs[].name],constraints}]}' + {"name":"create","formats":["xlsx"],"variants":[{"name":"xlsx","result_schema":"office.xlsx.create/1","inputs":["output","sheet","dry-run","overwrite","json"],"constraints":["output-extension=.xlsx","create-new-by-default","transactional-publication","bounded-candidate-package","candidate-max-entry-bytes=12582912","candidate-max-uncompressed-bytes=25165824"]}]} + + $ office.exe help batch --json | jq -c '.data.records[0] | {name,formats,variants:[.variants[]|{name,result_schema,outputs:[.outputs[].name],constraints}]}' + {"name":"batch","formats":["xlsx"],"variants":[{"name":"xlsx","result_schema":"office.xlsx.batch/1","outputs":["stats","transaction"],"constraints":["schema=xlsx.batch/1","overwrite-requires(out)","out-extension-must-match-input-format","transactional-publication","full-workbook-rewrite-on-change","zero-op-reuses-original","transaction-max-materialized-cells=32768","transaction-max-row-column-lines=32768","read-max-decoded-xml-bytes=16777216","read-max-markup-tokens=262144","read-max-materialized-row-column-dimensions=32768","read-max-row-column-dimension-work=32768","candidate-max-entry-bytes=12582912","candidate-max-uncompressed-bytes=25165824"]}]} + + $ office.exe create xlsx x3-created.xlsx --sheet Data --json | jq -c '{success,schema:.data.schema,sheet:.data.sheet,transaction_schema:.data.transaction.schema,mode:.data.transaction.mode,input:.data.transaction.input,original_size:.data.transaction.original_size,replaced_existing:.data.transaction.replaced_existing,overwritten_size:.data.transaction.overwritten_size,committed:.data.transaction.committed,validations:[.data.transaction.validations[].name],added:(.data.transaction.preservation.added|length>0)}' + {"success":true,"schema":"office.xlsx.create/1","sheet":"Data","transaction_schema":"office.transaction/2","mode":"create","input":null,"original_size":null,"replaced_existing":false,"overwritten_size":null,"committed":true,"validations":["office-xlsx-bounded-source","office-portable-opc","office-xlsx-bounded"],"added":true} + + $ office.exe identify x3-created.xlsx + xlsx + + $ printf '%s\n' '{"schema":"xlsx.batch/1","ops":[{"op":"set","params":{"sheet":"Data","cell":"A1","value":"one"}},{"op":"formula","params":{"sheet":"Data","cell":"B1","formula":"=LEN(A1)"}},{"op":"style","params":{"sheet":"Data","range":"A1:B1","bold":true}}]}' > x3-batch.json + $ office.exe batch x3-created.xlsx x3-batch.json --out x3-batched.xlsx --json | jq -c '{success,schema:.data.schema,stats:.data.stats,committed:.data.transaction.committed,changed:.data.transaction.changed,full_rewrite:any(.warnings[];.code=="office.xlsx.full_rewrite")}' + {"success":true,"schema":"office.xlsx.batch/1","stats":{"operation_count":3,"touched_cells":4,"style_cells":2,"row_column_lines":0,"new_style_records":1},"committed":true,"changed":true,"full_rewrite":true} + + $ office.exe batch x3-created.xlsx x3-batch.json --out x3-human.xlsx + committed: 3 operations, 4 touched cells -> x3-human.xlsx + warning [office.xlsx.full_rewrite]: XLSX batch mutation performs a full workbook serialization; the transaction preservation report is authoritative for changed, added, removed, and unchanged part payloads + warning [office.transaction.path_based_commit_semantics]: publication uses moonbitlang/async path APIs; atomic rename is guaranteed, but hostile concurrent directory-entry replacement is outside the portable contract + + $ office.exe get x3-batched.xlsx '/xlsx/sheet[name="Data"]/range[A1:B1]' --json | jq -c '{refs:[.data.cells[].reference],raw:[.data.cells[].raw],formulas:[.data.cells[]|(.formula // null)]}' + {"refs":["A1","B1"],"raw":[{"type":"string","value":"one"},null],"formulas":[null,"LEN(A1)"]} + + $ office.exe text x3-created.xlsx --json | jq -c '{matched_total:.data.matched_total,returned:.data.returned}' + {"matched_total":0,"returned":0} + +Dry-run validates the exact candidate without publishing, while malformed +scripts and application failures leave both source and requested destination +untouched. + + $ office.exe batch x3-created.xlsx x3-batch.json --out x3-dry.xlsx --dry-run --json | jq -c '{dry_run:.data.transaction.dry_run,committed:.data.transaction.committed,changed:.data.transaction.changed}' + {"dry_run":true,"committed":false,"changed":true} + $ test ! -e x3-dry.xlsx; echo $? + 0 + + $ cp x3-created.xlsx x3-options-before.xlsx + $ office.exe batch x3-created.xlsx x3-batch.json --overwrite --json > x3-batch-options.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-batch-options.json + {"success":false,"code":"office.invalid_arguments"} + $ cmp x3-created.xlsx x3-options-before.xlsx; echo $? + 0 + + $ office.exe batch x3-created.xlsx missing-script.json --overwrite --json > x3-missing-script-options.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-missing-script-options.json + {"success":false,"code":"office.invalid_arguments"} + + $ printf '%s\n' '{"schema":"xlsx.batch/1","ops":[],"typo":true}' > x3-invalid.json + $ office.exe batch x3-created.xlsx x3-invalid.json --out x3-invalid.xlsx --json > x3-invalid-result.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code,script:.error.details.script}' x3-invalid-result.json + {"success":false,"code":"office.xlsx.invalid_batch_script","script":"x3-invalid.json"} + $ test ! -e x3-invalid.xlsx; echo $? + 0 + + $ cp x3-created.xlsx x3-before.xlsx + $ printf '%s\n' '{"schema":"xlsx.batch/1","ops":[{"op":"set","params":{"sheet":"Missing","cell":"A1","value":"no"}}]}' > x3-operation-error.json + $ office.exe batch x3-created.xlsx x3-operation-error.json --out x3-operation-error.xlsx --json > x3-operation-result.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-operation-result.json + {"success":false,"code":"office.xlsx.batch_operation_failed"} + $ cmp x3-created.xlsx x3-before.xlsx; echo $? + 0 + $ test ! -e x3-operation-error.xlsx; echo $? + 0 + +Creation rejects invalid sheet names and existing destinations without an +explicit overwrite opt-in. + + $ office.exe create xlsx x3-invalid-sheet.xlsx --sheet bad/name --json > x3-invalid-sheet.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-invalid-sheet.json + {"success":false,"code":"office.xlsx.invalid_sheet_name"} + $ test ! -e x3-invalid-sheet.xlsx; echo $? + 0 + + $ office.exe create xlsx '' --json > x3-empty-output.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-empty-output.json + {"success":false,"code":"office.transaction.invalid_options"} + + $ office.exe create xlsx x3-created.xlsx --json > x3-exists.json 2>&1; echo $? + 1 + $ jq -c '{success,code:.error.code}' x3-exists.json + {"success":false,"code":"office.transaction.output_exists"} + + $ office.exe create xlsx x3-created.xlsx --sheet Replaced --overwrite + committed: created XLSX sheet "Replaced" -> x3-created.xlsx + warning [office.transaction.path_based_commit_semantics]: publication uses moonbitlang/async path APIs; atomic rename is guaranteed, but hostile concurrent directory-entry replacement is outside the portable contract + Structured DOCX reads share one bounded projection. Outline provides the map, get resolves a canonical path, text emits path-tagged paragraphs, and query uses deterministic document order and declared predicates. diff --git a/office/cmd/office/docx_read_output.mbt b/office/cmd/office/docx_read_output.mbt index f3df5954..c471f126 100644 --- a/office/cmd/office/docx_read_output.mbt +++ b/office/cmd/office/docx_read_output.mbt @@ -1291,7 +1291,7 @@ async fn docx_outline_human( let output = DocxHumanWriter::new(maximum) output.begin_line() output.write_plain("docx: ") - output.write_plain(raw_human_text(projection.file, 160)) + output.write_plain(human_text(projection.file, 160)) output.begin_line() output.write_plain("scanned elements: \{projection.entries.length()}") projection.cooperate(work=docx_cli_projection_yield_elements) diff --git a/office/cmd/office/main.mbt b/office/cmd/office/main.mbt index 55adb647..ca0dad71 100644 --- a/office/cmd/office/main.mbt +++ b/office/cmd/office/main.mbt @@ -138,6 +138,8 @@ fn build_command() -> @argparse.Command { get_command(), text_command(), query_command(), + create_command(), + batch_command(), raw_command(), ], ) @@ -146,7 +148,15 @@ fn build_command() -> @argparse.Command { ///| fn is_implemented_command(name : StringView) -> Bool { match name { - "help" | "identify" | "outline" | "get" | "text" | "query" | "raw" => true + "help" + | "identify" + | "outline" + | "get" + | "text" + | "query" + | "create" + | "batch" + | "raw" => true _ => false } } @@ -164,6 +174,8 @@ async fn run_cli(args : Array[String]) -> Unit { "get" => run_docx_get(values) "text" => run_docx_text(values) "query" => run_docx_query(values) + "create" => run_create(values) + "batch" => run_xlsx_batch(values) "raw" => run_raw(values) _ => () } diff --git a/office/cmd/office/main_wbtest.mbt b/office/cmd/office/main_wbtest.mbt index fba215bb..fd1ce854 100644 --- a/office/cmd/office/main_wbtest.mbt +++ b/office/cmd/office/main_wbtest.mbt @@ -3,11 +3,27 @@ test "capability registry claims only wired commands" { for command in @lib.capability_commands() { assert_true(is_implemented_command(command.name)) } - for name in ["help", "identify", "outline", "get", "text", "query", "raw"] { + for + name in [ + "help", "identify", "outline", "get", "text", "query", "create", "batch", "raw", + ] { assert_true(@lib.find_capability_command(name) is Some(_)) } } +///| +test "XLSX mutation help exposes only transactional bounded controls" { + let create = create_command().render_help() + assert_true(create.contains("xlsx")) + assert_false(create.to_lower().contains("docx")) + let batch = batch_command().render_help() + assert_true(batch.contains("--out")) + assert_true(batch.contains("--dry-run")) + assert_true(batch.contains("--overwrite")) + assert_false(batch.to_lower().contains("ppt")) + assert_false(batch.to_lower().contains("mcp")) +} + ///| test "identify generated help marks its file positional as required" { let help = identify_command().render_help() @@ -36,7 +52,7 @@ test "bounded diagnostics do not amplify attacker-controlled tokens" { let error = unknown_help_token("format", long, ["docx", "xlsx"]) assert_true(error.message.length() < 180) assert_false(error.message.contains("x".repeat(100))) - assert_eq(raw_human_text("line\n\u{1b}[31m", 40), "line\\n\\u001b[31m") + assert_eq(human_text("line\n\u{1b}[31m", 40), "line\\n\\u001b[31m") assert_eq( render_failure( @lib.protocol_error("office.test", "bad\u{1b}[31m\u{7}line\nnext"), diff --git a/office/cmd/office/moon.pkg b/office/cmd/office/moon.pkg index cff6e2d5..e0523e36 100644 --- a/office/cmd/office/moon.pkg +++ b/office/cmd/office/moon.pkg @@ -7,11 +7,13 @@ import { "bobzhang/docx2html/paths" @docx_paths, "bobzhang/docx2html/xml", "bobzhang/mbtexcel/inspect", + "bobzhang/mbtexcel/batch", "bobzhang/mbtexcel/xlsx", "bobzhang/mbtexcel/zip", "bobzhang/office" @lib, "bobzhang/office/raw", "bobzhang/office/transaction", + "bobzhang/office/xlsx" @office_xlsx, "moonbitlang/async", "moonbitlang/async/fs" @afs, "moonbitlang/core/argparse", diff --git a/office/cmd/office/output.mbt b/office/cmd/office/output.mbt index f0b63c47..b0b81f48 100644 --- a/office/cmd/office/output.mbt +++ b/office/cmd/office/output.mbt @@ -5,6 +5,25 @@ enum OutputMode { JsonLines } +///| +fn transaction_failure(error : @transaction.TransactionError) -> CliFailure { + CliFailure(error.to_protocol_error()) +} + +///| +fn unexpected_cli_failure( + code : String, + operation : String, + error : Error, +) -> CliFailure { + CliFailure( + @lib.protocol_error( + code, + "\{operation} failed: \{bounded_text(error.to_string(), 240)}", + ), + ) +} + ///| fn format_resource_failure( format : String, @@ -123,6 +142,24 @@ fn terminal_safe_text(value : String) -> String { output.to_string() } +///| +fn human_text(value : String, maximum : Int) -> String { + terminal_safe_text(bounded_text(value, maximum)) +} + +///| +fn human_transaction_status(report : @transaction.TransactionReport) -> String { + if report.dry_run { + "validated dry run" + } else if report.committed { + "committed" + } else if !report.changed { + "validated no-op" + } else { + "validated without publication" + } +} + ///| fn normalize_argument_error(value : String) -> String { let first_line = match value.find("\n") { diff --git a/office/cmd/office/raw.mbt b/office/cmd/office/raw.mbt index bd7e2468..d7f96201 100644 --- a/office/cmd/office/raw.mbt +++ b/office/cmd/office/raw.mbt @@ -17,25 +17,6 @@ fn raw_failure(error : @raw.RawError) -> CliFailure { CliFailure(error.to_protocol_error()) } -///| -fn transaction_failure(error : @transaction.TransactionError) -> CliFailure { - CliFailure(error.to_protocol_error()) -} - -///| -fn unexpected_cli_failure( - code : String, - operation : String, - error : Error, -) -> CliFailure { - CliFailure( - @lib.protocol_error( - code, - "\{operation} failed: \{bounded_text(error.to_string(), 240)}", - ), - ) -} - ///| fn raw_mutation_failure( error : @raw.RawError, @@ -70,17 +51,12 @@ fn cli_file_error(code : String, message : String, path : String) -> CliFailure CliFailure( @lib.protocol_error( code, - message + raw_human_text(path, 160), + message + human_text(path, 160), details=Json::object({ "file": Json::string(bounded_text(path, 160)) }), ), ) } -///| -fn raw_human_text(value : String, maximum : Int) -> String { - terminal_safe_text(bounded_text(value, maximum)) -} - ///| async fn read_bounded_file( path : String, @@ -242,12 +218,12 @@ fn raw_inventory_human(inventory : @raw.RawInventory) -> String { } else { let values = [] for alternate in aliases { - values.push(raw_human_text(alternate, 160)) + values.push(human_text(alternate, 160)) } " aliases=" + values.join(",") } lines.push( - "/\{raw_human_text(part.part_name(), 160)} \{kind} \{part.byte_size()} bytes \{raw_human_text(part.media_type(), 160)}" + + "/\{human_text(part.part_name(), 160)} \{kind} \{part.byte_size()} bytes \{human_text(part.media_type(), 160)}" + alias_text, ) } @@ -329,7 +305,7 @@ async fn run_raw_read(matches : @argparse.Matches) -> Unit { ) } else { println( - "wrote \{bytes.length()} bytes from /\{part.part_info().part_name()} to \{raw_human_text(path, 160)}", + "wrote \{bytes.length()} bytes from /\{part.part_info().part_name()} to \{human_text(path, 160)}", ) } } @@ -399,21 +375,6 @@ fn raw_result_json( }) } -///| -fn raw_human_transaction_status( - report : @transaction.TransactionReport, -) -> String { - if report.dry_run { - "validated dry run" - } else if report.committed { - "committed" - } else if !report.changed { - "validated no-op" - } else { - "validated without publication" - } -} - ///| fn raw_human_mutation_subject(mutation : @raw.RawMutation) -> String { if mutation.action_name() == "replace-part" { @@ -446,7 +407,7 @@ fn print_raw_result( }, ) println( - "\{raw_human_transaction_status(report)}: \{raw_human_mutation_subject(mutation)} -> \{raw_human_text(destination, 160)}", + "\{human_transaction_status(report)}: \{raw_human_mutation_subject(mutation)} -> \{human_text(destination, 160)}", ) } } diff --git a/office/cmd/office/xlsx_mutation.mbt b/office/cmd/office/xlsx_mutation.mbt new file mode 100644 index 00000000..6366571d --- /dev/null +++ b/office/cmd/office/xlsx_mutation.mbt @@ -0,0 +1,283 @@ +///| +fn xlsx_batch_failure(message : String, script : String) -> CliFailure { + CliFailure( + @lib.protocol_error( + "office.xlsx.invalid_batch_script", + bounded_text(message, 480), + details=Json::object({ "script": Json::string(bounded_text(script, 160)) }), + ), + ) +} + +///| +fn xlsx_batch_stats_json(stats : @batch.BatchPlanStats) -> Json { + Json::object({ + "operation_count": Json::number(stats.operation_count.to_double()), + "touched_cells": Json::number(stats.touched_cells.to_double()), + "style_cells": Json::number(stats.style_cells.to_double()), + "row_column_lines": Json::number(stats.row_column_lines.to_double()), + "new_style_records": Json::number(stats.new_style_records.to_double()), + }) +} + +///| +fn xlsx_batch_result_json(result : @office_xlsx.XlsxBatchResult) -> Json { + Json::object({ + "schema": Json::string(@lib.SCHEMA_XLSX_BATCH_RESULT), + "stats": xlsx_batch_stats_json(result.stats), + "transaction": result.transaction.to_json(), + }) +} + +///| +fn xlsx_create_result_json(result : @office_xlsx.XlsxCreateResult) -> Json { + Json::object({ + "schema": Json::string(@lib.SCHEMA_XLSX_CREATE_RESULT), + "sheet": Json::string(result.sheet), + "transaction": result.transaction.to_json(), + }) +} + +///| +fn transaction_warnings( + report : @transaction.TransactionReport, +) -> Array[@lib.ProtocolWarning] { + let warnings = [] + for warning in report.warning_records() { + warnings.push(warning) + } + warnings +} + +///| +fn xlsx_operation_count_text(count : Int) -> String { + if count == 1 { + "1 operation" + } else { + "\{count} operations" + } +} + +///| +fn xlsx_cell_count_text(count : Int64) -> String { + if count == 1L { + "1 touched cell" + } else { + "\{count} touched cells" + } +} + +///| +fn print_transaction_warnings(report : @transaction.TransactionReport) -> Unit { + for warning in report.warning_records() { + println( + "warning [\{human_text(warning.code, 160)}]: \{human_text(warning.message, 320)}", + ) + } +} + +///| +fn print_xlsx_create_result( + matches : @argparse.Matches, + output : String, + result : @office_xlsx.XlsxCreateResult, +) -> Unit { + if matches.flags.get_or_default("json", false) { + println( + @lib.output_success( + xlsx_create_result_json(result), + warnings=transaction_warnings(result.transaction), + ).stringify(indent=2), + ) + } else { + let subject = if result.transaction.committed { + "created XLSX sheet" + } else { + "XLSX sheet" + } + println( + "\{human_transaction_status(result.transaction)}: \{subject} \"\{human_text(result.sheet, 80)}\" -> \{human_text(output, 160)}", + ) + print_transaction_warnings(result.transaction) + } +} + +///| +fn print_xlsx_batch_result( + matches : @argparse.Matches, + destination : String, + result : @office_xlsx.XlsxBatchResult, +) -> Unit { + if matches.flags.get_or_default("json", false) { + println( + @lib.output_success( + xlsx_batch_result_json(result), + warnings=transaction_warnings(result.transaction), + ).stringify(indent=2), + ) + } else { + println( + "\{human_transaction_status(result.transaction)}: \{xlsx_operation_count_text(result.stats.operation_count)}, \{xlsx_cell_count_text(result.stats.touched_cells)} -> \{human_text(destination, 160)}", + ) + print_transaction_warnings(result.transaction) + } +} + +///| +fn checked_xlsx_create_options( + matches : @argparse.Matches, +) -> @transaction.CreateTransactionOptions raise CliFailure { + @transaction.create_transaction_options( + required_value(matches, "output"), + dry_run=matches.flags.get_or_default("dry-run", false), + overwrite=matches.flags.get_or_default("overwrite", false), + ) catch { + @transaction.TransactionError(..) as error => + raise transaction_failure(error) + error => + raise unexpected_cli_failure( + "office.xlsx.create_failed", "XLSX creation option validation", error, + ) + } +} + +///| +async fn run_create_xlsx(matches : @argparse.Matches) -> Unit { + let output = required_value(matches, "output") + let sheet = optional_value(matches, "sheet").unwrap_or("Sheet1") + // Keep normalized option failures outside the operation catch so its generic + // arm cannot rewrite an intentional `CliFailure` as a create failure. + let options = checked_xlsx_create_options(matches) + let result = @office_xlsx.create_workbook(options, sheet) catch { + @transaction.TransactionError(..) as error => + raise transaction_failure(error) + error if @async.is_being_cancelled() => raise error + error => + raise unexpected_cli_failure( + "office.xlsx.create_failed", "XLSX creation", error, + ) + } + print_xlsx_create_result(matches, output, result) +} + +///| +async fn run_xlsx_batch(matches : @argparse.Matches) -> Unit { + let file = required_value(matches, "file") + let script = required_value(matches, "script") + // Reject structurally invalid CLI combinations before any script I/O so + // argument failures are deterministic and independent of the script path. + let options = checked_transaction_options(matches) + let encoded = read_bounded_file( + script, + @batch.MAX_BATCH_SCRIPT_BYTES, + "office.xlsx.batch_script_read_failed", + "batch script", + limit_code="office.xlsx.resource_limit", + ) + let plan = @office_xlsx.parse_batch(encoded) catch { + BatchError(message) => raise xlsx_batch_failure(message, script) + } + let result = @office_xlsx.transact_batch(options, plan) catch { + @transaction.TransactionError(..) as error => + raise transaction_failure(error) + error if @async.is_being_cancelled() => raise error + error => + raise unexpected_cli_failure( + "office.xlsx.batch_failed", "XLSX batch transaction", error, + ) + } + let destination = optional_value(matches, "out").unwrap_or(file) + print_xlsx_batch_result(matches, destination, result) +} + +///| +fn create_command() -> @argparse.Command { + Command( + "create", + about="Create a validated Office document atomically", + subcommand_required=true, + subcommands=[ + Command( + "xlsx", + about="Create a validated workbook with one worksheet", + positionals=[ + PositionArg( + "output", + about="new .xlsx destination", + num_args=@argparse.ValueRange::single(), + ), + ], + options=[ + OptionArg( + "sheet", + long="sheet", + about="name of the first worksheet", + default_values=["Sheet1"], + ), + ], + flags=[ + FlagArg( + "dry-run", + long="dry-run", + about="validate without publishing", + ), + FlagArg( + "overwrite", + long="overwrite", + about="replace an existing regular-file destination", + ), + FlagArg("json", long="json", about="print office.output/1 JSON"), + ], + ), + ], + ) +} + +///| +fn batch_command() -> @argparse.Command { + Command( + "batch", + about="Apply one strict XLSX operation script transactionally", + positionals=[ + PositionArg( + "file", + about="existing .xlsx package", + num_args=@argparse.ValueRange::single(), + ), + PositionArg( + "script", + about="strict xlsx.batch/1 JSON script", + num_args=@argparse.ValueRange::single(), + ), + ], + options=[ + OptionArg( + "out", + long="out", + about="publish to a separate .xlsx path instead of replacing the input", + ), + ], + flags=[ + FlagArg("dry-run", long="dry-run", about="validate without publishing"), + FlagArg( + "overwrite", + long="overwrite", + about="allow replacement of an existing separate --out destination", + ), + FlagArg("json", long="json", about="print office.output/1 JSON"), + ], + ) +} + +///| +async fn run_create(matches : @argparse.Matches) -> Unit { + match matches.subcommand { + Some(("xlsx", values)) => run_create_xlsx(values) + _ => + raise CliFailure( + @lib.protocol_error( + "office.invalid_arguments", "create requires the xlsx document format", + ), + ) + } +} diff --git a/office/cmd/office/xlsx_mutation_wbtest.mbt b/office/cmd/office/xlsx_mutation_wbtest.mbt new file mode 100644 index 00000000..4d0511e6 --- /dev/null +++ b/office/cmd/office/xlsx_mutation_wbtest.mbt @@ -0,0 +1,50 @@ +///| +test "XLSX batch result accounting uses the parsed plan boundary" { + let script = + #|{"schema":"xlsx.batch/1","ops":[ + #| {"op":"set","params":{"sheet":"Data","cell":"A1","value":"one"}}, + #| {"op":"formula","params":{"sheet":"Data","cell":"B1","formula":"=LEN(A1)"}}, + #| {"op":"style","params":{"sheet":"Data","range":"A1:B1","bold":true}} + #|]} + let plan = @office_xlsx.parse_batch(@utf8.encode(script)) + guard xlsx_batch_stats_json(plan.stats()) is Object(fields) else { + fail("batch stats must be an object") + } + assert_eq(fields.get("operation_count"), Some(Json::number(3.0))) + assert_eq(fields.get("touched_cells"), Some(Json::number(4.0))) + assert_eq(fields.get("style_cells"), Some(Json::number(2.0))) + assert_eq(fields.get("new_style_records"), Some(Json::number(1.0))) +} + +///| +test "XLSX batch script failures retain a stable bounded protocol code" { + let long_path = "script-" + "x".repeat(500) + let CliFailure(error) = xlsx_batch_failure( + "unknown top-level key 'typo'", long_path, + ) + assert_eq(error.code, "office.xlsx.invalid_batch_script") + guard error.details is Some(Object(details)) else { + fail("batch failure must identify its script") + } + guard details.get("script") is Some(String(path)) else { + fail("batch failure script detail must be a string") + } + assert_true(path.length() < long_path.length()) +} + +///| +test "XLSX create and batch capability variants declare transactional schemas" { + guard @lib.find_capability_command("create") is Some(create) else { + fail("create capability must exist") + } + assert_eq(create.formats, ["xlsx"]) + assert_eq(create.variants.length(), 1) + assert_eq(create.variants[0].result_schema, @lib.SCHEMA_XLSX_CREATE_RESULT) + guard @lib.find_capability_command("batch") is Some(batch) else { + fail("batch capability must exist") + } + assert_eq(batch.formats, ["xlsx"]) + assert_eq(batch.variants.length(), 1) + assert_eq(batch.variants[0].result_schema, @lib.SCHEMA_XLSX_BATCH_RESULT) + assert_true(batch.variants[0].constraints.contains("zero-op-reuses-original")) +} diff --git a/office/pkg.generated.mbti b/office/pkg.generated.mbti index 2eea13bb..044666c1 100644 --- a/office/pkg.generated.mbti +++ b/office/pkg.generated.mbti @@ -27,7 +27,11 @@ pub const SCHEMA_RAW_PART : String = "office.raw.part/1" pub const SCHEMA_RAW_RESULT : String = "office.raw.result/1" -pub const SCHEMA_TRANSACTION : String = "office.transaction/1" +pub const SCHEMA_TRANSACTION : String = "office.transaction/2" + +pub const SCHEMA_XLSX_BATCH_RESULT : String = "office.xlsx.batch/1" + +pub const SCHEMA_XLSX_CREATE_RESULT : String = "office.xlsx.create/1" pub const SCHEMA_XLSX_ELEMENT : String = "office.xlsx.element/1" @@ -47,6 +51,18 @@ pub const SELECTOR_MAX_SELECTIONS : Int = 32 pub const SELECTOR_MAX_VALUE_LENGTH : Int = 256 +pub const XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES : Int = 25165824 + +pub const XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES : Int = 12582912 + +pub const XLSX_TRANSACTION_MAX_DECODED_XML_BYTES : Int = 16777216 + +pub const XLSX_TRANSACTION_MAX_MATERIALIZED_CELLS : Int = 32_768 + +pub const XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES : Int = 32_768 + +pub const XLSX_TRANSACTION_MAX_XML_MARKUP_TOKENS : Int = 262_144 + pub fn capabilities_data(format? : DocumentFormat, operation? : String) -> Json pub fn capability_commands() -> Array[CapabilityCommand] diff --git a/office/transaction/create.mbt b/office/transaction/create.mbt new file mode 100644 index 00000000..fe56fe7a --- /dev/null +++ b/office/transaction/create.mbt @@ -0,0 +1,298 @@ +///| +/// Immutable destination policy for creating one fresh Office package. +pub struct CreateTransactionOptions { + output_path : String + dry_run : Bool + overwrite : Bool + permission : Int +} + +///| +/// Builds checked fresh-package transaction options. Creation is no-replace by +/// default; `overwrite=true` atomically replaces an existing regular-file +/// destination only after the complete candidate validation boundary passes. +pub fn create_transaction_options( + output_path : String, + dry_run? : Bool = false, + overwrite? : Bool = false, + permission? : Int = 0o600, +) -> CreateTransactionOptions raise TransactionError { + checked_path(output_path, "output_path") + if permission < 0 || permission > 0o777 { + raise transaction_error( + "office.transaction.invalid_options", + "permission must be between 0000 and 0777", + details=Json::object({ "field": Json::string("permission") }), + ) + } + { output_path, dry_run, overwrite, permission } +} + +///| +/// Resolves and pins the destination parent through the same publication +/// policy used by existing-package transactions. The source fields are an +/// inert duplicate of the destination: `publish_candidate` never consults +/// them for `Output` mode, while retaining one cleanup path for both flows. +async fn resolve_create_policy( + options : CreateTransactionOptions, +) -> ResolvedPolicy { + reject_ambiguous_platform_path(options.output_path, "output_path") + let destination_exists = path_exists_checked(options.output_path) + let destination_target = if destination_exists { + let kind = @afs.kind(filesystem_path(options.output_path)) catch { + error if @async.is_being_cancelled() => raise error + error => + raise transaction_error( + "office.transaction.destination_check_failed", + "could not inspect destination '\{bounded_text(options.output_path, 160)}': \{bounded_text(error.to_string(), 240)}", + details=Json::object({ + "output": Json::string(bounded_text(options.output_path, 160)), + }), + ) + } + if kind is Directory { + raise transaction_error( + "office.transaction.invalid_options", "output_path must name a file, not a directory", + ) + } + canonical_existing_path(options.output_path) catch { + error if @async.is_being_cancelled() => raise error + error => + raise transaction_error( + "office.transaction.destination_check_failed", + "could not resolve destination '\{bounded_text(options.output_path, 160)}': \{bounded_text(error.to_string(), 240)}", + details=Json::object({ + "output": Json::string(bounded_text(options.output_path, 160)), + }), + ) + } + } else { + resolve_new_destination(options.output_path) + } + if destination_exists && !options.overwrite { + raise transaction_error( + "office.transaction.output_exists", + "destination already exists; set overwrite=true to replace it", + details=Json::object({ + "output": Json::string(bounded_text(options.output_path, 160)), + }), + ) + } + let destination_directory = publication_directory_open( + path_parent(destination_target), + options.output_path, + ) + try { + let destination_leaf = path_leaf(destination_target) + let pinned_kind = publication_entry_kind( + destination_directory, + destination_leaf, + options.output_path, + ) + if pinned_kind == 2 { + raise transaction_error( + "office.transaction.invalid_options", "output_path must name a file, not a directory", + ) + } + if pinned_kind > 1 { + raise transaction_error( + "office.transaction.invalid_options", "output_path must name a regular file", + ) + } + if pinned_kind == 1 && !options.overwrite { + raise transaction_error( + "office.transaction.output_exists", + "destination already exists; set overwrite=true to replace it", + details=Json::object({ + "output": Json::string(bounded_text(options.output_path, 160)), + }), + ) + } + let overwrite_baseline_size = if pinned_kind == 1 { + Some( + publication_entry_size( + destination_directory, + destination_leaf, + options.output_path, + ), + ) + } else { + None + } + { + source_target: destination_target, + source_directory: publication_directory_duplicate( + destination_directory, + options.output_path, + ), + source_leaf: destination_leaf, + destination_target, + destination_directory, + destination_leaf, + output_path: options.output_path, + mode: Create, + replace: options.overwrite, + overwrite_baseline_size, + } + } catch { + error => { + publication_directory_close(destination_directory) + raise error + } + } +} + +///| +/// A fresh package has no preservation baseline. Every candidate part is +/// therefore reported as added, while the policy text explicitly refuses to +/// imply payload or container preservation across an overwrite. +fn creation_preservation_report( + candidate_archive : @zip.Archive, +) -> PreservationReport { + let added = [] + for entry in candidate_archive.entries() { + added.push(entry.name()) + } + added.sort() + { + whole_file_identical: false, + manifest_enforced: false, + changed: readonly_strings([]), + added: readonly_strings(added), + removed: readonly_strings([]), + unchanged: readonly_strings([]), + zip_metadata_policy: "fresh package creation has no source preservation baseline; all candidate entries are reported as added", + } +} + +///| +/// Internal fault-injectable implementation shared with transaction wbtests. +async fn transact_create_with_faults( + options : CreateTransactionOptions, + format : @office.DocumentFormat, + create : (TransactionBudget) -> TransactionMutation raise, + validators : Array[TransactionValidator], + identifier : TransactionIdentifier?, + faults : TransactionFaults, + cleanup_release? : String, + materialization_limit? : Int64 = transaction_max_live_materialized_bytes, + materialization_reserve? : Int64 = transaction_materialization_working_reserve, +) -> TransactionReport { + if materialization_limit <= 0L || + materialization_reserve < 0L || + materialization_reserve >= materialization_limit { + raise transaction_error( + "office.transaction.invalid_contract", "transaction materialization limit must exceed its nonnegative working reserve", + ) + } + let validators = validators.copy() + validate_validator_set(validators, identifier) + let policy = resolve_create_policy(options) + defer publication_directory_close(policy.source_directory) + defer publication_directory_close(policy.destination_directory) + // Make cancellation observable before the synchronous package builder and + // its serializer begin allocating the candidate. + @async.pause() + let mutation_retained = materialization_reserve + let budget = transaction_mutation_budget( + mutation_retained, materialization_limit, materialization_reserve, + ) + let pending = create(budget) catch { + TransactionError(..) as error => raise normalize_mutation_error(error) + error => + raise transaction_error( + "office.transaction.mutation_failed", + "creation callback failed: \{bounded_text(error.to_string(), 240)}", + ) + } + match pending.result { + ReuseOriginal => + raise transaction_error( + "office.transaction.invalid_contract", "fresh package creation must return a serialized candidate", + ) + Serialized(_) => () + } + let mutation = enforce_returned_candidate_budget( + pending, b"", budget, mutation_retained, materialization_limit, + ) + let (validations, candidate_archive) = validate_candidate( + format, + policy.destination_target, + b"", + @zip.Archive::new(), + mutation, + validators, + identifier, + mutation_retained, + materialization_limit, + ) + let preservation = creation_preservation_report(candidate_archive) + let should_commit = !options.dry_run + let publication_options : TransactionOptions = { + input_path: options.output_path, + output_path: Some(options.output_path), + dry_run: options.dry_run, + overwrite: options.overwrite, + permission: options.permission, + } + let (parent_synced, temp_clean) = if should_commit { + publish_candidate( + publication_options, + policy, + b"", + mutation.bytes, + faults, + cleanup_release, + ) + } else { + (true, true) + } + let warnings = [] + for warning in mutation.warnings { + warnings.push(warning) + } + append_platform_warnings(warnings, should_commit, parent_synced, temp_clean) + { + format, + input_path: None, + output_path: options.output_path, + mode: Create, + dry_run: options.dry_run, + changed: true, + committed: should_commit, + original_size: None, + result_size: mutation.bytes.length(), + replaced_existing: should_commit && + policy.overwrite_baseline_size is Some(_), + overwritten_size: if should_commit { + policy.overwrite_baseline_size + } else { + None + }, + validations: ReadOnlyArray::from_array(validations), + preservation, + warnings: ReadOnlyArray::from_array(warnings), + } +} + +///| +/// Builds, validates, and atomically publishes one fresh Office package. The +/// callback must serialize through `TransactionBudget::max_candidate_package_bytes` +/// before allocating its candidate. Validation and dry-run semantics are the +/// same as `transact`; no destination bytes are touched until every gate passes. +pub async fn transact_create( + options : CreateTransactionOptions, + format : @office.DocumentFormat, + create : (TransactionBudget) -> TransactionMutation raise, + validators? : Array[TransactionValidator] = [], + identifier? : TransactionIdentifier, +) -> TransactionReport { + transact_create_with_faults( + options, + format, + create, + validators, + identifier, + no_transaction_faults(), + ) +} diff --git a/office/transaction/create_wbtest.mbt b/office/transaction/create_wbtest.mbt new file mode 100644 index 00000000..e2c59584 --- /dev/null +++ b/office/transaction/create_wbtest.mbt @@ -0,0 +1,148 @@ +///| +fn wb_fresh_xlsx(sheet : String) -> Bytes raise { + let workbook = @excel.new_workbook() + ignore(workbook.add_sheet(sheet)) + @excel.write(workbook) +} + +///| +fn wb_create_faults( + fail_write? : Bool = false, + fail_write_after_bytes? : Int, + fail_rename? : Bool = false, + stall_write_after_bytes? : Int, + concurrent_destination? : Bytes, +) -> TransactionFaults { + { + fail_write, + fail_write_after_bytes, + stall_write_after_bytes, + fail_rename, + stall_after_temp: false, + post_rename_release: None, + concurrent_replacement: None, + concurrent_destination, + relocate_parent_to: None, + deny_cleanup: false, + } +} + +///| +async test "fresh creation write and rename failures clean private staging" { + wb_with_transaction_dir(directory => { + let candidate = wb_fresh_xlsx("Sheet1") + let write_output = "\{directory}/write.xlsx" + let write_code = wb_error_code(() => { + transact_create_with_faults( + create_transaction_options(write_output), + Xlsx, + _budget => transaction_mutation(candidate), + [], + None, + wb_create_faults(fail_write=true), + ) + }) + assert_eq(write_code, "office.transaction.write_failed") + assert_false(@afs.exists(write_output)) + wb_assert_no_temp(directory) + let rename_output = "\{directory}/rename.xlsx" + let rename_code = wb_error_code(() => { + transact_create_with_faults( + create_transaction_options(rename_output), + Xlsx, + _budget => transaction_mutation(candidate), + [], + None, + wb_create_faults(fail_rename=true), + ) + }) + assert_eq(rename_code, "office.transaction.commit_failed") + assert_false(@afs.exists(rename_output)) + wb_assert_no_temp(directory) + }) +} + +///| +async test "fresh creation no-replace loses safely to a concurrent destination" { + wb_with_transaction_dir(directory => { + let output = "\{directory}/race.xlsx" + let candidate = wb_fresh_xlsx("Sheet1") + let concurrent = b"concurrent writer" + let code = wb_error_code(() => { + transact_create_with_faults( + create_transaction_options(output), + Xlsx, + _budget => transaction_mutation(candidate), + [], + None, + wb_create_faults(concurrent_destination=concurrent), + ) + }) + assert_eq(code, "office.transaction.output_exists") + assert_eq(@afs.read_file(output).binary(), concurrent) + wb_assert_no_temp(directory) + }) +} + +///| +async test "fresh creation cancellation waits for staging cleanup" { + wb_with_transaction_dir(directory => { + let output = "\{directory}/cancel.xlsx" + let candidate = wb_fresh_xlsx("Sheet1") + @async.with_task_group() <| group => { + let task = group.spawn(() => { + transact_create_with_faults( + create_transaction_options(output), + Xlsx, + _budget => transaction_mutation(candidate), + [], + None, + wb_create_faults(stall_write_after_bytes=1), + ) + }) + let mut observed_staging = false + for _ in 0..<1_000 { + for name in @afs.readdir(directory) { + if name.contains(".office-tmp-") { + observed_staging = true + } + } + if observed_staging { + break + } + @async.pause() + } + assert_true(observed_staging) + task.cancel() + let propagated = try { + ignore(task.wait()) + false + } catch { + error => @async.is_cancellation_error(error) + } + assert_true(propagated) + } + assert_false(@afs.exists(output)) + wb_assert_no_temp(directory) + }) +} + +///| +async test "fresh creation rejects source-reuse mutation contracts" { + wb_with_transaction_dir(directory => { + let output = "\{directory}/reuse.xlsx" + let code = wb_error_code(() => { + transact_create_with_faults( + create_transaction_options(output), + Xlsx, + _budget => transaction_reuse_original(), + [], + None, + no_transaction_faults(), + ) + }) + assert_eq(code, "office.transaction.invalid_contract") + assert_false(@afs.exists(output)) + wb_assert_no_temp(directory) + }) +} diff --git a/office/transaction/moon.pkg b/office/transaction/moon.pkg index 832945d0..d3f6046f 100644 --- a/office/transaction/moon.pkg +++ b/office/transaction/moon.pkg @@ -21,6 +21,7 @@ import { } for "test" import { + "bobzhang/mbtexcel" @excel, "bobzhang/docx2html" @word, "bobzhang/docx2html/splice", "moonbitlang/async", diff --git a/office/transaction/pkg.generated.mbti b/office/transaction/pkg.generated.mbti index 4a5165f0..44949e9f 100644 --- a/office/transaction/pkg.generated.mbti +++ b/office/transaction/pkg.generated.mbti @@ -10,8 +10,12 @@ import { // Values pub async fn atomic_write_new(String, BytesView, permission? : Int) -> Unit +pub fn create_transaction_options(String, dry_run? : Bool, overwrite? : Bool, permission? : Int) -> CreateTransactionOptions raise TransactionError + pub async fn transact(TransactionOptions, (@office.DocumentFormat, Bytes, @zip.Archive, TransactionBudget) -> TransactionMutation raise, validators? : Array[TransactionValidator], identifier? : TransactionIdentifier) -> TransactionReport +pub async fn transact_create(CreateTransactionOptions, @office.DocumentFormat, (TransactionBudget) -> TransactionMutation raise, validators? : Array[TransactionValidator], identifier? : TransactionIdentifier) -> TransactionReport + pub fn transaction_failure(String, String, details? : Json) -> TransactionError raise TransactionError pub fn transaction_identifier(String, (String, BytesView, @zip.Archive) -> @office.DocumentFormat raise) -> TransactionIdentifier raise TransactionError @@ -42,6 +46,13 @@ pub fn TransactionError::to_string(Self) -> String pub impl Show for TransactionError // Types and methods +pub struct CreateTransactionOptions { + output_path : String + dry_run : Bool + overwrite : Bool + permission : Int +} + pub struct PreservationReport { whole_file_identical : Bool manifest_enforced : Bool @@ -74,6 +85,7 @@ pub struct TransactionIdentifier { pub(all) enum TransactionMode { InPlace Output + Create } derive(Eq, @debug.Debug) #deprecated pub fn TransactionMode::equal(Self, Self) -> Bool @@ -97,14 +109,16 @@ pub struct TransactionOptions { pub struct TransactionReport { format : @office.DocumentFormat - input_path : String + input_path : String? output_path : String mode : TransactionMode dry_run : Bool changed : Bool committed : Bool - original_size : Int + original_size : Int? result_size : Int + replaced_existing : Bool + overwritten_size : Int64? validations : ReadOnlyArray[ValidationSummary] preservation : PreservationReport warnings : ReadOnlyArray[@office.ProtocolWarning] diff --git a/office/transaction/publication_io.mbt b/office/transaction/publication_io.mbt index a1772e5e..f1bf697c 100644 --- a/office/transaction/publication_io.mbt +++ b/office/transaction/publication_io.mbt @@ -96,6 +96,47 @@ async fn publication_entry_kind( } } +///| +/// Observes the size of an existing regular-file destination without reading +/// its payload. The report records this as the pre-validation overwrite +/// baseline; path-based hosts cannot promise identity across hostile concurrent +/// directory-entry replacement, which is surfaced separately as a platform +/// warning on committed transactions. +async fn publication_entry_size( + directory : PublicationDirectory, + leaf : String, + output_path : String, +) -> Int64 { + let path = join_path(directory.path, leaf) + let file = @afs.open( + path, + mode=ReadOnly, + create_mode=OpenExisting, + sync=NoSync, + ) catch { + error if @async.is_being_cancelled() => raise error + error => + raise transaction_error( + "office.transaction.destination_check_failed", + "could not inspect overwrite baseline '\{bounded_text(output_path, 160)}': \{bounded_text(error.to_string(), 240)}", + ) + } + defer file.close() + if !(file.kind() is Regular) { + raise transaction_error( + "office.transaction.destination_check_failed", "overwrite baseline is no longer a regular file", + ) + } + file.size() catch { + error if @async.is_being_cancelled() => raise error + error => + raise transaction_error( + "office.transaction.destination_check_failed", + "could not size overwrite baseline '\{bounded_text(output_path, 160)}': \{bounded_text(error.to_string(), 240)}", + ) + } +} + ///| fn publication_entries_same( left : PublicationDirectory, diff --git a/office/transaction/transaction.mbt b/office/transaction/transaction.mbt index dc34f0fa..85896e89 100644 --- a/office/transaction/transaction.mbt +++ b/office/transaction/transaction.mbt @@ -79,6 +79,7 @@ priv struct ResolvedPolicy { output_path : String mode : TransactionMode replace : Bool + overwrite_baseline_size : Int64? } ///| @@ -362,6 +363,7 @@ async fn resolve_policy(options : TransactionOptions) -> ResolvedPolicy { output_path: options.input_path, mode: InPlace, replace: true, + overwrite_baseline_size: None, } } Some(output_path) => { @@ -452,6 +454,15 @@ async fn resolve_policy(options : TransactionOptions) -> ResolvedPolicy { }), ) } + let overwrite_baseline_size = if pinned_kind == 1 { + Some( + publication_entry_size( + destination_directory, destination_leaf, output_path, + ), + ) + } else { + None + } { source_target, source_directory, @@ -462,6 +473,7 @@ async fn resolve_policy(options : TransactionOptions) -> ResolvedPolicy { output_path, mode: Output, replace: options.overwrite, + overwrite_baseline_size, } } catch { error => { @@ -921,6 +933,10 @@ fn validate_candidate( } for validator in validators { let findings = validator.run(format, mutation.bytes, archive.fork()) catch { + TransactionError(..) as error => + raise normalize_transaction_error( + error, "office.transaction.validation_failed", "validator raised an invalid transaction error", + ) error => raise transaction_error( "office.transaction.validation_failed", @@ -1094,7 +1110,7 @@ async fn publish_candidate( error => raise error } } - if policy.mode is Output { + if policy.mode is Output || policy.mode is Create { match faults.concurrent_destination { Some(replacement) => @afs.write_file( @@ -1327,14 +1343,21 @@ async fn transact_with_identifier_and_faults( append_platform_warnings(warnings, should_commit, parent_synced, temp_clean) { format, - input_path: options.input_path, + input_path: Some(options.input_path), output_path: policy.output_path, mode: policy.mode, dry_run: options.dry_run, changed, committed: should_commit, - original_size: original.length(), + original_size: Some(original.length()), result_size: mutation.bytes.length(), + replaced_existing: should_commit && + policy.overwrite_baseline_size is Some(_), + overwritten_size: if should_commit { + policy.overwrite_baseline_size + } else { + None + }, validations: ReadOnlyArray::from_array(validations), preservation, warnings: ReadOnlyArray::from_array(warnings), diff --git a/office/transaction/transaction_test.mbt b/office/transaction/transaction_test.mbt index debfacf9..96e38b9d 100644 --- a/office/transaction/transaction_test.mbt +++ b/office/transaction/transaction_test.mbt @@ -206,6 +206,111 @@ async fn transaction_error_value( } } +///| +fn fresh_xlsx_candidate(sheet : String) -> Bytes raise { + let workbook = @excel.new_workbook() + ignore(workbook.add_sheet(sheet)) + @excel.write(workbook) +} + +///| +async test "fresh package transaction validates and atomically creates XLSX" { + with_transaction_test_dir(directory => { + let output = "\{directory}/fresh.xlsx" + let candidate = fresh_xlsx_candidate("Data") + let report = @transaction.transact_create( + @transaction.create_transaction_options(output), + Xlsx, + budget => { + assert_true(candidate.length() <= budget.max_candidate_package_bytes()) + @transaction.transaction_mutation(candidate) + }, + ) + assert_true(report.changed) + assert_true(report.committed) + assert_false(report.dry_run) + assert_eq(report.mode, Create) + assert_true(report.input_path is None) + assert_true(report.original_size is None) + assert_false(report.replaced_existing) + assert_true(report.overwritten_size is None) + assert_eq(report.result_size, candidate.length()) + assert_eq(@afs.read_file(output).binary(), candidate) + assert_no_transaction_temp(directory) + }) +} + +///| +async test "fresh package dry-run validates without creating destination" { + with_transaction_test_dir(directory => { + let output = "\{directory}/dry-run.xlsx" + let candidate = fresh_xlsx_candidate("Sheet1") + let report = @transaction.transact_create( + @transaction.create_transaction_options(output, dry_run=true), + Xlsx, + _budget => @transaction.transaction_mutation(candidate), + ) + assert_true(report.changed) + assert_false(report.committed) + assert_true(report.dry_run) + assert_false(@afs.exists(output)) + assert_no_transaction_temp(directory) + }) +} + +///| +async test "fresh package create is no-replace unless overwrite is explicit" { + with_transaction_test_dir(directory => { + let output = "\{directory}/existing.xlsx" + let original = b"existing non-package bytes" + let candidate = fresh_xlsx_candidate("Replacement") + write_test_file(output, original) + let invoked = Ref(false) + let code = transaction_error_code(() => { + @transaction.transact_create( + @transaction.create_transaction_options(output), + Xlsx, + _budget => { + invoked.val = true + @transaction.transaction_mutation(candidate) + }, + ) + }) + assert_eq(code, "office.transaction.output_exists") + assert_false(invoked.val) + assert_eq(@afs.read_file(output).binary(), original) + let report = @transaction.transact_create( + @transaction.create_transaction_options(output, overwrite=true), + Xlsx, + _budget => @transaction.transaction_mutation(candidate), + ) + assert_true(report.committed) + assert_true(report.replaced_existing) + assert_eq(report.overwritten_size, Some(original.length().to_int64())) + assert_eq(@afs.read_file(output).binary(), candidate) + assert_no_transaction_temp(directory) + }) +} + +///| +async test "fresh package validation failure preserves existing destination" { + with_transaction_test_dir(directory => { + let output = "\{directory}/invalid.xlsx" + let original = b"keep me" + write_test_file(output, original) + let code = transaction_error_code(() => { + @transaction.transact_create( + @transaction.create_transaction_options(output, overwrite=true), + Xlsx, + _budget => @transaction.transaction_mutation(b"not an Office package"), + ) + }) + assert_eq(code, "office.transaction.validation_failed") + assert_eq(@afs.read_file(output).binary(), original) + assert_no_transaction_temp(directory) + }) +} + ///| fn assert_identifier_format_error( error : @transaction.TransactionError, @@ -1181,6 +1286,29 @@ async test "successful and throwing validators run before publication" { ) }) assert_eq(code, "office.transaction.validation_failed") + let resource_validator = @transaction.transaction_validator( + "docx-resource", + (_format, _bytes, _archive) => { + raise @transaction.transaction_failure( + "office.transaction.resource_limit_exceeded", + "validator exhausted its bounded resource allowance", + details=Json::object({ + "phase": Json::string("validator"), + "kind": Json::string("test_budget"), + }), + ) + }, + ) + let resource_code = transaction_error_code(() => { + @transaction.transact( + @transaction.transaction_options(input, output_path=output), + (_format, bytes, _archive, _budget) => { + @transaction.transaction_mutation(bytes) + }, + validators=[resource_validator], + ) + }) + assert_eq(resource_code, "office.transaction.resource_limit_exceeded") assert_false(@afs.exists(output)) assert_eq(@afs.read_file(input).binary(), original) assert_no_transaction_temp(directory) diff --git a/office/transaction/transaction_wbtest.mbt b/office/transaction/transaction_wbtest.mbt index 2f552916..38681fb3 100644 --- a/office/transaction/transaction_wbtest.mbt +++ b/office/transaction/transaction_wbtest.mbt @@ -1623,14 +1623,16 @@ test "post-return budget requires explicit original reuse" { test "transaction output schema is deterministic and nests A2 warnings" { let report : TransactionReport = { format: Docx, - input_path: "input.docx", + input_path: Some("input.docx"), output_path: "output.docx", mode: Output, dry_run: false, changed: true, committed: true, - original_size: 10, + original_size: Some(10), result_size: 12, + replaced_existing: true, + overwritten_size: Some(8L), validations: ReadOnlyArray::from_array([ { name: "office-portable-opc", passed: true, finding_count: 0 }, ]), @@ -1651,7 +1653,7 @@ test "transaction output schema is deterministic and nests A2 warnings" { "schema": "office.output/1", "success": true, "data": { - "schema": "office.transaction/1", + "schema": "office.transaction/2", "format": "docx", "input": "input.docx", "output": "output.docx", @@ -1661,6 +1663,8 @@ test "transaction output schema is deterministic and nests A2 warnings" { "committed": true, "original_size": 10, "result_size": 12, + "replaced_existing": true, + "overwritten_size": 8, "validations": [ { "name": "office-portable-opc", "passed": true, "finding_count": 0 }, ], diff --git a/office/transaction/types.mbt b/office/transaction/types.mbt index 3b59786e..5d64bfad 100644 --- a/office/transaction/types.mbt +++ b/office/transaction/types.mbt @@ -781,6 +781,7 @@ pub fn transaction_reuse_original_with_manifest( pub(all) enum TransactionMode { InPlace Output + Create } derive(Debug, Eq) ///| @@ -788,6 +789,7 @@ pub fn TransactionMode::name(self : TransactionMode) -> String { match self { InPlace => "in-place" Output => "output" + Create => "create" } } @@ -815,14 +817,16 @@ pub struct PreservationReport { /// Deterministic result of a validated Office transaction. pub struct TransactionReport { format : @office.DocumentFormat - input_path : String + input_path : String? output_path : String mode : TransactionMode dry_run : Bool changed : Bool committed : Bool - original_size : Int + original_size : Int? result_size : Int + replaced_existing : Bool + overwritten_size : Int64? validations : ReadOnlyArray[ValidationSummary] preservation : PreservationReport warnings : ReadOnlyArray[@office.ProtocolWarning] @@ -869,7 +873,9 @@ fn preservation_json(report : PreservationReport) -> Json { } ///| -/// Serializes the report as the `office.transaction/1` data object. +/// Serializes the report as the `office.transaction/2` data object. Fresh +/// creation has no input or original size; committed overwrites explicitly +/// report the observed pre-validation destination size. pub fn TransactionReport::to_json(self : TransactionReport) -> Json { let validations = [] for validation in self.validations { @@ -878,14 +884,25 @@ pub fn TransactionReport::to_json(self : TransactionReport) -> Json { Json::object({ "schema": Json::string(@office.SCHEMA_TRANSACTION), "format": Json::string(self.format.name()), - "input": Json::string(self.input_path), + "input": match self.input_path { + Some(path) => Json::string(path) + None => Json::null() + }, "output": Json::string(self.output_path), "mode": Json::string(self.mode.name()), "dry_run": Json::boolean(self.dry_run), "changed": Json::boolean(self.changed), "committed": Json::boolean(self.committed), - "original_size": Json::number(self.original_size.to_double()), + "original_size": match self.original_size { + Some(size) => Json::number(size.to_double()) + None => Json::null() + }, "result_size": Json::number(self.result_size.to_double()), + "replaced_existing": Json::boolean(self.replaced_existing), + "overwritten_size": match self.overwritten_size { + Some(size) => Json::number(size.to_double()) + None => Json::null() + }, "validations": Json::array(validations), "preservation": preservation_json(self.preservation), }) diff --git a/office/xlsx/moon.pkg b/office/xlsx/moon.pkg new file mode 100644 index 00000000..e1e2e55d --- /dev/null +++ b/office/xlsx/moon.pkg @@ -0,0 +1,17 @@ +import { + "bobzhang/mbtexcel" @excel, + "bobzhang/mbtexcel/batch", + "bobzhang/mbtexcel/xlsx" @engine, + "bobzhang/mbtexcel/zip", + "bobzhang/office", + "bobzhang/office/transaction", + "moonbitlang/core/json", +} + +import { + "moonbitlang/async", + "moonbitlang/async/fs" @afs, + "moonbitlang/core/encoding/utf8", +} for "test" + +supported_targets = "native+wasm" diff --git a/office/xlsx/pkg.generated.mbti b/office/xlsx/pkg.generated.mbti new file mode 100644 index 00000000..05af8254 --- /dev/null +++ b/office/xlsx/pkg.generated.mbti @@ -0,0 +1,32 @@ +// Generated using `moon info`, DON'T EDIT IT +package "bobzhang/office/xlsx" + +import { + "bobzhang/mbtexcel/batch", + "bobzhang/office/transaction", +} + +// Values +pub async fn create_workbook(@transaction.CreateTransactionOptions, String) -> XlsxCreateResult + +pub fn parse_batch(BytesView) -> @batch.BatchPlan raise @batch.BatchError + +pub async fn transact_batch(@transaction.TransactionOptions, @batch.BatchPlan) -> XlsxBatchResult + +// Errors + +// Types and methods +pub struct XlsxBatchResult { + stats : @batch.BatchPlanStats + transaction : @transaction.TransactionReport +} + +pub struct XlsxCreateResult { + sheet : String + transaction : @transaction.TransactionReport +} + +// Type aliases + +// Traits + diff --git a/office/xlsx/sdk_validity/moon.pkg b/office/xlsx/sdk_validity/moon.pkg new file mode 100644 index 00000000..6de72615 --- /dev/null +++ b/office/xlsx/sdk_validity/moon.pkg @@ -0,0 +1,12 @@ +import { + "bobzhang/office", + "bobzhang/office/xlsx", + "bobzhang/office/transaction", + "bobzhang/mbtexcel" @excel, + "moonbitlang/async", + "moonbitlang/async/fs" @afs, + "moonbitlang/async/process", + "moonbitlang/core/encoding/utf8", +} for "test" + +supported_targets = "native" diff --git a/office/xlsx/sdk_validity/pkg.generated.mbti b/office/xlsx/sdk_validity/pkg.generated.mbti new file mode 100644 index 00000000..86d85655 --- /dev/null +++ b/office/xlsx/sdk_validity/pkg.generated.mbti @@ -0,0 +1,13 @@ +// Generated using `moon info`, DON'T EDIT IT +package "bobzhang/office/xlsx/sdk_validity" + +// Values + +// Errors + +// Types and methods + +// Type aliases + +// Traits + diff --git a/office/xlsx/sdk_validity/xlsx_sdk_test.mbt b/office/xlsx/sdk_validity/xlsx_sdk_test.mbt new file mode 100644 index 00000000..64d5dd77 --- /dev/null +++ b/office/xlsx/sdk_validity/xlsx_sdk_test.mbt @@ -0,0 +1,72 @@ +///| +async fn validate_xlsx_transaction_with_sdk(path : String) -> Unit { + let (code, stdout, stderr) = @process.collect_output("bash", [ + "../scripts/validate_xlsx.sh", path, + ]) + if code != 0 { + let out = stdout.text() catch { _ => "" } + let err = stderr.text() catch { _ => "" } + fail( + "OpenXML SDK validation failed (exit=\{code}) for \{path}\nstdout:\n\{out}\nstderr:\n\{err}", + ) + } +} + +///| +async test "transactional XLSX create and batch outputs pass Microsoft OpenXML validation" { + @async.with_task_group() <| group => { + let directory = @afs.tmpdir(prefix="office-xlsx-sdk-") + group.add_defer() <| () => { + @async.protect_from_cancel(() => @afs.rmdir(directory, recursive=true)) + } + let created = "\{directory}/created.xlsx" + let batched = "\{directory}/batched.xlsx" + let creation = @xlsx.create_workbook( + @transaction.create_transaction_options(created), + "Data", + ) + assert_true(creation.transaction.committed) + validate_xlsx_transaction_with_sdk(created) + + let script = + #|{"schema":"xlsx.batch/1","ops":[ + #| {"op":"set","params":{"sheet":"Data","cell":"A1","value":"Region"}}, + #| {"op":"set","params":{"sheet":"Data","cell":"B1","value":"Sales"}}, + #| {"op":"set","params":{"sheet":"Data","cell":"A2","value":"East"}}, + #| {"op":"set","params":{"sheet":"Data","cell":"B2","value":120}}, + #| {"op":"set","params":{"sheet":"Data","cell":"A3","value":"West"}}, + #| {"op":"set","params":{"sheet":"Data","cell":"B3","value":87.5}}, + #| {"op":"formula","params":{"sheet":"Data","cell":"B4","formula":"=SUM(B2:B3)"}}, + #| {"op":"style","params":{"sheet":"Data","range":"A1:B1","bold":true,"fill":"4472C4","font_color":"FFFFFF","align":"center"}}, + #| {"op":"width","params":{"sheet":"Data","column":"A:B","width":14}}, + #| {"op":"freeze","params":{"sheet":"Data","cell":"A2"}}, + #| {"op":"filter","params":{"sheet":"Data","range":"A1:B3"}}, + #| {"op":"chart","params":{"sheet":"Data","anchor":"E2","type":"col","categories":"A2:A3","values":"B2:B3","name":"Sales","title":"By region"}}, + #| {"op":"cf","params":{"sheet":"Data","range":"B2:B4","type":"3_color_scale","min_color":"F8696B","mid_color":"FFEB84","max_color":"63BE7B"}}, + #| {"op":"row","params":{"sheet":"Data","action":"height","at":1,"height":22}}, + #| {"op":"add-sheet","params":{"name":"Summary"}}, + #| {"op":"set","params":{"sheet":"Summary","cell":"A1","value":"Item"}}, + #| {"op":"set","params":{"sheet":"Summary","cell":"B1","value":"Count"}}, + #| {"op":"table","params":{"sheet":"Summary","range":"A1:B4","name":"Items","style":"TableStyleMedium2"}}, + #| {"op":"validate","params":{"sheet":"Summary","range":"D2:D20","type":"list","values":["Low","High"]}} + #|]} + let plan = @xlsx.parse_batch(@utf8.encode(script)) + let batch = @xlsx.transact_batch( + @transaction.transaction_options(created, output_path=batched), + plan, + ) + assert_true(batch.transaction.committed) + assert_eq(batch.stats.operation_count, 19) + assert_true( + batch.transaction + .warning_records() + .any(warning => warning.code == "office.xlsx.full_rewrite"), + ) + validate_xlsx_transaction_with_sdk(batched) + + let reopened = @excel.read(@afs.read_file(batched).binary()) + assert_eq(reopened.get_cell("Data", "A2"), Some("East")) + assert_eq(reopened.get_cell_formula("Data", "B4"), Some("SUM(B2:B3)")) + assert_eq(reopened.get_sheet_list(), ["Data", "Summary"]) + } +} diff --git a/office/xlsx/transaction.mbt b/office/xlsx/transaction.mbt new file mode 100644 index 00000000..73d541b8 --- /dev/null +++ b/office/xlsx/transaction.mbt @@ -0,0 +1,371 @@ +///| +let xlsx_transaction_validator_name = "office-xlsx-bounded" + +///| +let xlsx_transaction_identifier_name = "office-xlsx-bounded-source" + +///| +let xlsx_transaction_max_findings = 64 + +///| +let xlsx_transaction_max_message_chars = 480 + +///| +/// The A4 transaction reserves 64 MiB for format-specific parser and serializer +/// state. The public Office policy constants divide that reserve between +/// bounded decoded XML/cell objects, the generated uncompressed archive, and +/// transient serializer indexes. + +///| +fn bounded_xlsx_message(text : String, limit : Int) -> String { + let output = StringBuilder::new() + let mut count = 0 + for character in text { + if count == limit { + output.write_string("…") |> ignore + break + } + output.write_char(character) |> ignore + count = count + 1 + } + output.to_string() +} + +///| +fn xlsx_transaction_failure( + code : String, + message : String, + details? : Json, +) -> @transaction.TransactionError raise @transaction.TransactionError { + match details { + Some(value) => + @transaction.transaction_failure(code, message, details=value) + None => @transaction.transaction_failure(code, message) + } +} + +///| +fn xlsx_read_limits() -> @engine.ReadLimits raise @engine.XlsxError { + @engine.ReadLimits::with_values( + max_xml_part_bytes=@office.XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES, + max_total_xml_bytes=@office.XLSX_TRANSACTION_MAX_DECODED_XML_BYTES, + max_xml_markup_tokens=@office.XLSX_TRANSACTION_MAX_XML_MARKUP_TOKENS, + max_materialized_cells=@office.XLSX_TRANSACTION_MAX_MATERIALIZED_CELLS, + max_materialized_row_column_dimensions=@office.XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES, + max_row_column_dimension_work=@office.XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES, + ) +} + +///| +fn read_transaction_workbook( + archive : @zip.Archive, + candidate_validation? : Bool = false, +) -> @engine.Workbook raise @transaction.TransactionError { + let phase = if candidate_validation { + "candidate validation" + } else { + "source parsing" + } + let invalid_code = if candidate_validation { + "office.xlsx.candidate_invalid_package" + } else { + "office.xlsx.invalid_package" + } + @engine.read_bounded_archive(archive, limits=xlsx_read_limits()) catch { + ResourceLimitExceeded(kind~, limit~, actual~) => + raise xlsx_transaction_failure( + "office.transaction.resource_limit_exceeded", + "XLSX \{phase} exceeded its bounded materialization policy", + details=Json::object({ + "phase": Json::string(phase), + "kind": Json::string(kind), + "limit": Json::number(limit.to_double()), + "actual": Json::number(actual.to_double()), + }), + ) + error => + raise xlsx_transaction_failure( + invalid_code, + "could not read the bounded XLSX package during \{phase}: " + + bounded_xlsx_message(repr(error), xlsx_transaction_max_message_chars), + ) + } +} + +///| +fn serialize_transaction_workbook( + workbook : @engine.Workbook, + budget : @transaction.TransactionBudget, +) -> Bytes raise @transaction.TransactionError { + let maximum = budget.max_candidate_package_bytes() + if maximum <= 0 { + raise xlsx_transaction_failure( + "office.transaction.resource_limit_exceeded", + "no live-memory allowance remains for an XLSX candidate package", + details=Json::object({ + "phase": Json::string("candidate package"), + "kind": Json::string("package_bytes"), + "limit": Json::number(0.0), + "actual": Json::number(1.0), + }), + ) + } + let entry_limit = if budget.max_entry_uncompressed_bytes() < + @office.XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES { + budget.max_entry_uncompressed_bytes() + } else { + @office.XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES + } + let archive_limit = if budget.max_total_uncompressed_bytes() < + @office.XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES { + budget.max_total_uncompressed_bytes() + } else { + @office.XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES + } + let limits = @engine.WriteLimits::with_values( + max_output_bytes=maximum, + max_archive_entries=budget.max_archive_entries(), + max_entry_uncompressed_bytes=entry_limit, + max_total_uncompressed_bytes=archive_limit, + ) catch { + error => + raise xlsx_transaction_failure( + "office.transaction.invalid_contract", + "could not construct the bounded XLSX write policy: " + + bounded_xlsx_message(repr(error), xlsx_transaction_max_message_chars), + ) + } + @engine.write_limited(workbook, limits) catch { + ResourceLimitExceeded(kind~, limit~, actual~) => + raise xlsx_transaction_failure( + "office.transaction.resource_limit_exceeded", + "XLSX candidate serialization exceeded its bounded construction policy", + details=Json::object({ + "phase": Json::string("candidate serialization"), + "kind": Json::string(kind), + "limit": Json::number(limit.to_double()), + "actual": Json::number(actual.to_double()), + }), + ) + error => + raise xlsx_transaction_failure( + "office.xlsx.serialization_failed", + "could not serialize XLSX candidate: " + + bounded_xlsx_message(repr(error), xlsx_transaction_max_message_chars), + ) + } +} + +///| +fn enforce_batch_materialization_budget( + workbook : @engine.Workbook, + stats : @batch.BatchPlanStats, +) -> Unit raise @transaction.TransactionError { + let existing = workbook.materialized_cell_count().to_int64() + let maximum = @office.XLSX_TRANSACTION_MAX_MATERIALIZED_CELLS.to_int64() + let requested = existing + stats.touched_cells + if requested > maximum { + raise xlsx_transaction_failure( + "office.transaction.resource_limit_exceeded", + "XLSX batch could exceed the bounded workbook cell materialization allowance", + details=Json::object({ + "phase": Json::string("batch application"), + "kind": Json::string("materialized_cells"), + "limit": Json::number(maximum.to_double()), + "actual": Json::number(requested.to_double()), + }), + ) + } + let existing_lines = workbook + .materialized_row_column_dimension_count() + .to_int64() + let requested_lines = existing_lines + stats.row_column_lines + if requested_lines > @office.XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES.to_int64() { + raise xlsx_transaction_failure( + "office.transaction.resource_limit_exceeded", + "XLSX batch could exceed the bounded row/column materialization allowance", + details=Json::object({ + "phase": Json::string("batch application"), + "kind": Json::string("row_column_lines"), + "limit": Json::number( + @office.XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES.to_double(), + ), + "actual": Json::number(requested_lines.to_double()), + }), + ) + } +} + +///| +fn xlsx_transaction_validator() -> @transaction.TransactionValidator raise @transaction.TransactionError { + @transaction.transaction_validator(xlsx_transaction_validator_name, ( + format, + _bytes, + archive, + ) => { + if !(format is Xlsx) { + raise xlsx_transaction_failure( + "office.xlsx.format_mismatch", + "XLSX validation received a \{format.name()} candidate", + ) + } + // A complete bounded parse catches semantic/package-graph defects the + // format-neutral portable detector intentionally does not interpret. + ignore(read_transaction_workbook(archive.fork(), candidate_validation=true)) + let problems = @engine.validate_ooxml_bounded_archive( + archive, + limits=xlsx_read_limits(), + ) + let findings = [] + let retained = if problems.length() < xlsx_transaction_max_findings { + problems.length() + } else { + xlsx_transaction_max_findings - 1 + } + for index in 0.. retained { + findings.push( + @transaction.validation_finding( + "office.xlsx.package_findings_omitted", "additional XLSX package findings were omitted after the bounded validator limit", + ), + ) + } + findings + }) +} + +///| +/// Runs the XLSX-specific bounded parser before the mandatory portable OPC +/// detector. The portable detector still independently verifies the format, +/// but it never sees relationship metadata that exceeded the XLSX package +/// materialization policy. +fn xlsx_transaction_identifier() -> @transaction.TransactionIdentifier raise @transaction.TransactionError { + @transaction.transaction_identifier(xlsx_transaction_identifier_name, ( + _path, + _bytes, + archive, + ) => { + ignore(read_transaction_workbook(archive)) + Xlsx + }) +} + +///| +fn full_rewrite_warning() -> @office.ProtocolWarning { + @office.protocol_warning( + "office.xlsx.full_rewrite", "XLSX batch mutation performs a full workbook serialization; the transaction preservation report is authoritative for changed, added, removed, and unchanged part payloads", + ) +} + +///| +/// Result of one strict XLSX batch transaction. +pub struct XlsxBatchResult { + stats : @batch.BatchPlanStats + transaction : @transaction.TransactionReport +} + +///| +/// Result of one fresh XLSX creation transaction. +pub struct XlsxCreateResult { + sheet : String + transaction : @transaction.TransactionReport +} + +///| +/// Parses one encoded, strict and resource-bounded `xlsx.batch/1` plan. +pub fn parse_batch( + data : BytesView, +) -> @batch.BatchPlan raise @batch.BatchError { + @batch.parse_script_bytes(data) +} + +///| +/// Applies a parsed plan to an existing XLSX package through the shared Office +/// transaction boundary. A true in-place zero-op plan reuses the exact source +/// bytes; a separate `output_path` still publishes a validated copy. +pub async fn transact_batch( + options : @transaction.TransactionOptions, + plan : @batch.BatchPlan, +) -> XlsxBatchResult { + let stats = plan.stats() + let validator = xlsx_transaction_validator() + let identifier = xlsx_transaction_identifier() + let report = @transaction.transact( + options, + (format, _bytes, archive, budget) => { + if !(format is Xlsx) { + raise xlsx_transaction_failure( + "office.xlsx.format_mismatch", + "office batch requires an XLSX package, received \{format.name()}", + ) + } + let workbook = read_transaction_workbook(archive) + enforce_batch_materialization_budget(workbook, stats) + plan.apply(workbook) catch { + BatchError(message) => + raise xlsx_transaction_failure( + "office.xlsx.batch_operation_failed", + bounded_xlsx_message(message, xlsx_transaction_max_message_chars), + ) + } + if plan.operation_count() == 0 { + @transaction.transaction_reuse_original() + } else { + let candidate = serialize_transaction_workbook(workbook, budget) + @transaction.transaction_mutation(candidate, warnings=[ + full_rewrite_warning(), + ]) + } + }, + validators=[validator], + identifier~, + ) + { stats, transaction: report } +} + +///| +/// Creates one fresh XLSX workbook with exactly one named worksheet, validates +/// the complete candidate and publishes it atomically unless this is a dry run. +pub async fn create_workbook( + options : @transaction.CreateTransactionOptions, + sheet : String, +) -> XlsxCreateResult { + let validator = xlsx_transaction_validator() + let identifier = xlsx_transaction_identifier() + let report = @transaction.transact_create( + options, + Xlsx, + budget => { + let workbook = @excel.new_workbook() + ignore( + workbook.add_sheet(sheet) catch { + error => + raise xlsx_transaction_failure( + "office.xlsx.invalid_sheet_name", + "could not create the first worksheet: " + + bounded_xlsx_message( + repr(error), + xlsx_transaction_max_message_chars, + ), + ) + }, + ) + @transaction.transaction_mutation( + serialize_transaction_workbook(workbook, budget), + ) + }, + validators=[validator], + identifier~, + ) + { sheet, transaction: report } +} diff --git a/office/xlsx/transaction_test.mbt b/office/xlsx/transaction_test.mbt new file mode 100644 index 00000000..0cd1685a --- /dev/null +++ b/office/xlsx/transaction_test.mbt @@ -0,0 +1,305 @@ +///| +async fn with_xlsx_transaction_dir(run : async (String) -> Unit) -> Unit { + @async.with_task_group() <| group => { + let directory = @afs.tmpdir(prefix="office-xlsx-transaction-") + group.add_defer() <| () => { + @async.protect_from_cancel(() => @afs.rmdir(directory, recursive=true)) + } + run(directory) + } +} + +///| +fn xlsx_transaction_fixture() -> Bytes raise { + let workbook = @excel.new_workbook() + ignore(workbook.add_sheet("Data")) + workbook.set_cell("Data", "A1", "before") + @excel.write(workbook) +} + +///| +async fn write_xlsx_transaction_file(path : String, bytes : Bytes) -> Unit { + @afs.write_file(path, bytes, create_mode=CreateNew, permission=0o600) +} + +///| +async fn xlsx_transaction_error_code( + run : async () -> @transaction.TransactionReport, +) -> String { + try run() catch { + @transaction.TransactionError(code~, ..) => code + error => fail("expected TransactionError, received \{error}") + } noraise { + _ => fail("expected transaction failure") + } +} + +///| +test "XLSX batch parsing is the encoded shared boundary" { + let plan = @xlsx.parse_batch( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"Data\",\"cell\":\"A1\",\"value\":\"after\"}}]}", + ), + ) + assert_eq(plan.operation_count(), 1) + assert_eq(plan.stats().touched_cells, 1L) +} + +///| +async test "fresh XLSX creation uses complete validation and atomic publication" { + with_xlsx_transaction_dir(directory => { + let output = "\{directory}/created.xlsx" + let result = @xlsx.create_workbook( + @transaction.create_transaction_options(output), + "Data", + ) + assert_eq(result.sheet, "Data") + assert_true(result.transaction.committed) + assert_true(result.transaction.changed) + assert_eq(result.transaction.validations.length(), 3) + assert_eq( + result.transaction.validations[0].name, + "office-xlsx-bounded-source", + ) + assert_eq(result.transaction.validations[1].name, "office-portable-opc") + assert_eq(result.transaction.validations[2].name, "office-xlsx-bounded") + let reopened = @excel.read(@afs.read_file(output).binary()) + assert_eq(reopened.get_sheet_list(), ["Data"]) + assert_true(result.transaction.preservation.added.length() > 0) + }) +} + +///| +async test "fresh XLSX creation normalizes invalid worksheet names" { + with_xlsx_transaction_dir(directory => { + let output = "\{directory}/invalid.xlsx" + let code = try { + ignore( + @xlsx.create_workbook( + @transaction.create_transaction_options(output), + "bad/name", + ), + ) + "" + } catch { + @transaction.TransactionError(code~, ..) => code + error => fail("expected TransactionError, received \{error}") + } + assert_eq(code, "office.xlsx.invalid_sheet_name") + assert_false(@afs.exists(output)) + }) +} + +///| +async test "XLSX batch applies once and reports full rewrite preservation" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let script = + #|{"schema":"xlsx.batch/1","ops":[ + #| {"op":"set","params":{"sheet":"Data","cell":"A1","value":"after"}}, + #| {"op":"formula","params":{"sheet":"Data","cell":"B1","formula":"=LEN(A1)"}}, + #| {"op":"style","params":{"sheet":"Data","range":"A1:B1","bold":true}} + #|]} + let plan = @xlsx.parse_batch(@utf8.encode(script)) + let result = @xlsx.transact_batch( + @transaction.transaction_options(input), + plan, + ) + assert_eq(result.stats.operation_count, 3) + assert_eq(result.stats.touched_cells, 4L) + assert_true(result.transaction.changed) + assert_true(result.transaction.committed) + let warnings = result.transaction.warning_records() + assert_true(warnings.length() >= 1) + assert_eq(warnings[0].code, "office.xlsx.full_rewrite") + let reopened = @excel.read(@afs.read_file(input).binary()) + assert_eq(reopened.get_cell("Data", "A1"), Some("after")) + assert_eq(reopened.get_cell_formula("Data", "B1"), Some("LEN(A1)")) + assert_true(reopened.get_cell_style("Data", "A1").unwrap_or(0) > 0) + assert_true(result.transaction.preservation.changed.length() > 0) + }) +} + +///| +async test "XLSX dry-run validates the candidate without publishing" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let plan = @xlsx.parse_batch( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"Data\",\"cell\":\"A1\",\"value\":\"dry\"}}]}", + ), + ) + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output, dry_run=true), + plan, + ) + assert_true(result.transaction.dry_run) + assert_false(result.transaction.committed) + assert_true(result.transaction.changed) + assert_eq(@afs.read_file(input).binary(), original) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "XLSX operation failure leaves source and destination untouched" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let plan = @xlsx.parse_batch( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"Missing\",\"cell\":\"A1\",\"value\":1}}]}", + ), + ) + let code = xlsx_transaction_error_code(() => { + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output), + plan, + ) + result.transaction + }) + assert_eq(code, "office.xlsx.batch_operation_failed") + assert_eq(@afs.read_file(input).binary(), original) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "XML-forbidden batch text cannot publish a candidate" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let plan = @xlsx.parse_batch( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"set\",\"params\":{\"sheet\":\"Data\",\"cell\":\"A1\",\"value\":\"before\\u0001after\"}}]}", + ), + ) + let code = xlsx_transaction_error_code(() => { + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output), + plan, + ) + result.transaction + }) + assert_eq(code, "office.xlsx.serialization_failed") + assert_eq(@afs.read_file(input).binary(), original) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "XLSX batch rejects projected cell growth before application" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let plan = @xlsx.parse_batch( + @utf8.encode( + "{\"schema\":\"xlsx.batch/1\",\"ops\":[{\"op\":\"style\",\"params\":{\"sheet\":\"Data\",\"range\":\"A1:A32768\",\"bold\":true}}]}", + ), + ) + let code = xlsx_transaction_error_code(() => { + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output), + plan, + ) + result.transaction + }) + assert_eq(code, "office.transaction.resource_limit_exceeded") + assert_eq(@afs.read_file(input).binary(), original) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "oversized relationship metadata fails closed before publication" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + let archive = @zip.read(original) + let target = "x".repeat( + @engine.ReadLimits::new().max_relationship_target_chars() + 1, + ) + archive.add( + "xl/custom/_rels/item.xml.rels", + @utf8.encode( + "", + ), + ) + let hostile = @zip.write(archive) + write_xlsx_transaction_file(input, hostile) + let plan = @xlsx.parse_batch( + @utf8.encode("{\"schema\":\"xlsx.batch/1\",\"ops\":[]}"), + ) + let code = xlsx_transaction_error_code(() => { + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output), + plan, + ) + result.transaction + }) + assert_eq(code, "office.transaction.resource_limit_exceeded") + assert_eq(@afs.read_file(input).binary(), hostile) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "XLSX batch preflights table header synthesis before application" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let output = "\{directory}/output.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let script = + #|{"schema":"xlsx.batch/1","ops":[ + #| {"op":"table","params":{"sheet":"Data","range":"A1:XFD2"}}, + #| {"op":"table","params":{"sheet":"Data","range":"A4:XFD5"}}, + #| {"op":"table","params":{"sheet":"Data","range":"A7:XFD8"}} + #|]} + let plan = @xlsx.parse_batch(@utf8.encode(script)) + assert_eq(plan.stats().touched_cells, 49_152L) + let code = xlsx_transaction_error_code(() => { + let result = @xlsx.transact_batch( + @transaction.transaction_options(input, output_path=output), + plan, + ) + result.transaction + }) + assert_eq(code, "office.transaction.resource_limit_exceeded") + assert_eq(@afs.read_file(input).binary(), original) + assert_false(@afs.exists(output)) + }) +} + +///| +async test "zero-op in-place XLSX batch reuses exact source bytes" { + with_xlsx_transaction_dir(directory => { + let input = "\{directory}/input.xlsx" + let original = xlsx_transaction_fixture() + write_xlsx_transaction_file(input, original) + let plan = @xlsx.parse_batch( + @utf8.encode("{\"schema\":\"xlsx.batch/1\",\"ops\":[]}"), + ) + let result = @xlsx.transact_batch( + @transaction.transaction_options(input), + plan, + ) + assert_false(result.transaction.changed) + assert_false(result.transaction.committed) + assert_true(result.transaction.preservation.whole_file_identical) + assert_eq(result.transaction.warning_records().length(), 0) + assert_eq(@afs.read_file(input).binary(), original) + }) +} diff --git a/office/xlsx/transaction_wbtest.mbt b/office/xlsx/transaction_wbtest.mbt new file mode 100644 index 00000000..15183509 --- /dev/null +++ b/office/xlsx/transaction_wbtest.mbt @@ -0,0 +1,51 @@ +///| +fn bounded_invalid_xlsx_archive() -> @zip.Archive raise { + let source = @zip.Archive::new() + source.add("payload.bin", b"not an XLSX workbook") + let limits = xlsx_read_limits() + @zip.read_limited( + @zip.write(source), + max_package_bytes=limits.max_package_bytes(), + max_entries=limits.max_archive_entries(), + max_entry_uncompressed_bytes=limits.max_entry_uncompressed_bytes(), + max_total_uncompressed_bytes=limits.max_total_uncompressed_bytes(), + max_total_preserved_source_bytes=limits.max_total_preserved_source_bytes(), + ) +} + +///| +test "candidate workbook read failures report the validation phase" { + let result : Result[@engine.Workbook, Error] = Ok( + read_transaction_workbook( + bounded_invalid_xlsx_archive(), + candidate_validation=true, + ), + ) catch { + error => Err(error) + } + match result { + Err(@transaction.TransactionError(code~, message~, details~)) => { + assert_eq(code, "office.xlsx.candidate_invalid_package") + assert_true(message.contains("candidate validation")) + assert_true(details is None) + } + _ => fail("expected candidate-specific XLSX validation error") + } +} + +///| +test "source workbook read failures retain the source phase" { + let result : Result[@engine.Workbook, Error] = Ok( + read_transaction_workbook(bounded_invalid_xlsx_archive()), + ) catch { + error => Err(error) + } + match result { + Err(@transaction.TransactionError(code~, message~, details~)) => { + assert_eq(code, "office.xlsx.invalid_package") + assert_true(message.contains("source parsing")) + assert_true(details is None) + } + _ => fail("expected source-specific XLSX read error") + } +} diff --git a/office/xlsx_limits.mbt b/office/xlsx_limits.mbt new file mode 100644 index 00000000..b9ed90fb --- /dev/null +++ b/office/xlsx_limits.mbt @@ -0,0 +1,27 @@ +///| +/// Maximum aggregate XML bytes decoded by one transaction-backed XLSX parse. +/// Together with the token and cell ceilings, this consumes a bounded slice of +/// the shared transaction working reserve. +pub const XLSX_TRANSACTION_MAX_DECODED_XML_BYTES : Int = 16 * 1024 * 1024 + +///| +/// Maximum XML markup-token starts scanned by one transaction-backed parse. +pub const XLSX_TRANSACTION_MAX_XML_MARKUP_TOKENS : Int = 262_144 + +///| +/// Maximum concrete worksheet cells retained by a transaction-backed workbook. +pub const XLSX_TRANSACTION_MAX_MATERIALIZED_CELLS : Int = 32_768 + +///| +/// Maximum aggregate uncompressed bytes retained in a generated XLSX archive +/// before ZIP output sizing and allocation. +pub const XLSX_TRANSACTION_MAX_CANDIDATE_ARCHIVE_BYTES : Int = 24 * 1024 * 1024 + +///| +/// Maximum uncompressed bytes retained for one generated XLSX archive entry. +pub const XLSX_TRANSACTION_MAX_CANDIDATE_ENTRY_BYTES : Int = 12 * 1024 * 1024 + +///| +/// Maximum row/column dimension records an Office batch may project before it +/// applies the plan to a workbook. +pub const XLSX_TRANSACTION_MAX_ROW_COLUMN_LINES : Int = 32_768 diff --git a/ooxml/content_types.mbt b/ooxml/content_types.mbt index 1677dd8b..df273001 100644 --- a/ooxml/content_types.mbt +++ b/ooxml/content_types.mbt @@ -41,27 +41,35 @@ fn ContentTypes::add_override( ///| fn ContentTypes::to_xml(self : ContentTypes) -> String { - let sb = StringBuilder::new() + self.to_xml_with_limit(None).unwrap() +} + +///| +fn ContentTypes::to_xml_with_limit( + self : ContentTypes, + maximum : Int?, +) -> String? { + let sb = XmlOutputBuilder::new(maximum) sb.write_view("\n") sb.write_view( "\n", ) for item in self.defaults { sb.write_view(" \n") } for item in self.overrides { sb.write_view(" \n") } sb.write_view("") - sb.to_string() + sb.finish() } ///| diff --git a/ooxml/manifest.mbt b/ooxml/manifest.mbt index 0ed65b66..8521c78a 100644 --- a/ooxml/manifest.mbt +++ b/ooxml/manifest.mbt @@ -138,16 +138,55 @@ pub fn WorkbookManifest::content_types_xml(self : WorkbookManifest) -> String { self.content_types.to_xml() } +///| +/// Serializes `[Content_Types].xml` only while its UTF-8 output fits `maximum`. +pub fn WorkbookManifest::content_types_xml_limited( + self : WorkbookManifest, + maximum : Int, +) -> String? { + if maximum < 0 { + None + } else { + self.content_types.to_xml_with_limit(Some(maximum)) + } +} + ///| pub fn WorkbookManifest::root_rels_xml(self : WorkbookManifest) -> String { self.root_rels.to_xml() } +///| +/// Serializes the package relationships only while UTF-8 output fits `maximum`. +pub fn WorkbookManifest::root_rels_xml_limited( + self : WorkbookManifest, + maximum : Int, +) -> String? { + if maximum < 0 { + None + } else { + self.root_rels.to_xml_with_limit(Some(maximum)) + } +} + ///| pub fn WorkbookManifest::workbook_rels_xml(self : WorkbookManifest) -> String { self.workbook_rels.to_xml() } +///| +/// Serializes workbook relationships only while UTF-8 output fits `maximum`. +pub fn WorkbookManifest::workbook_rels_xml_limited( + self : WorkbookManifest, + maximum : Int, +) -> String? { + if maximum < 0 { + None + } else { + self.workbook_rels.to_xml_with_limit(Some(maximum)) + } +} + ///| pub fn WorkbookManifest::add_content_default( self : WorkbookManifest, diff --git a/ooxml/manifest_test.mbt b/ooxml/manifest_test.mbt index eb3ae7cf..46c1ab0c 100644 --- a/ooxml/manifest_test.mbt +++ b/ooxml/manifest_test.mbt @@ -56,3 +56,44 @@ test "workbook manifest options and customization" { inspect(id, content="rId4") inspect(manifest.workbook_rels_xml().contains("example.xml"), content="true") } + +///| +test "workbook manifest limited serializers stop at exact UTF-8 ceilings" { + let manifest = @ooxml.WorkbookManifest::new(2) + manifest.add_content_default("<&\"'", "application/testé&xml") + manifest.add_workbook_relationship( + "http://example.com/type&escaped", "custom/é.xml", + ) + let content_types = manifest.content_types_xml() + let root_rels = manifest.root_rels_xml() + let workbook_rels = manifest.workbook_rels_xml() + // Each non-ASCII `é` occupies one String code unit and two UTF-8 bytes. + let content_types_bytes = content_types.length() + 1 + let workbook_rels_bytes = workbook_rels.length() + 1 + inspect( + manifest.content_types_xml_limited(content_types_bytes) == + Some(content_types), + content="true", + ) + inspect( + manifest.content_types_xml_limited(content_types_bytes - 1) is None, + content="true", + ) + inspect( + manifest.root_rels_xml_limited(root_rels.length()) == Some(root_rels), + content="true", + ) + inspect( + manifest.root_rels_xml_limited(root_rels.length() - 1) is None, + content="true", + ) + inspect( + manifest.workbook_rels_xml_limited(workbook_rels_bytes) == + Some(workbook_rels), + content="true", + ) + inspect( + manifest.workbook_rels_xml_limited(workbook_rels_bytes - 1) is None, + content="true", + ) +} diff --git a/ooxml/pkg.generated.mbti b/ooxml/pkg.generated.mbti index debdbf5d..9fdf787e 100644 --- a/ooxml/pkg.generated.mbti +++ b/ooxml/pkg.generated.mbti @@ -8,6 +8,8 @@ import { // Values pub fn attr_value(StringView, StringView) -> String? raise ParseXmlError +pub fn attr_value_view(StringView, StringView) -> StringView? raise ParseXmlError + pub fn canonicalize_xml_expanded_names(StringView, ArrayView[String], ArrayView[(String, String)], max_output_chars? : Int, cancelled? : () -> Bool, element_prefixes? : ArrayView[(String, String)]) -> String raise ParseXmlError pub fn decode_xml_attribute(StringView, cancelled? : () -> Bool) -> String raise ParseXmlError @@ -94,10 +96,13 @@ pub fn WorkbookManifest::add_custom_properties(Self) -> Unit pub fn WorkbookManifest::add_workbook_relationship(Self, String, String) -> Unit pub fn WorkbookManifest::add_workbook_relationship_with_id(Self, String, String) -> String pub fn WorkbookManifest::content_types_xml(Self) -> String +pub fn WorkbookManifest::content_types_xml_limited(Self, Int) -> String? pub fn WorkbookManifest::new(Int, include_shared_strings? : Bool, include_calc_chain? : Bool) -> Self pub fn WorkbookManifest::root_rels_xml(Self) -> String +pub fn WorkbookManifest::root_rels_xml_limited(Self, Int) -> String? pub fn WorkbookManifest::set_workbook_content_type(Self, String) -> Unit pub fn WorkbookManifest::workbook_rels_xml(Self) -> String +pub fn WorkbookManifest::workbook_rels_xml_limited(Self, Int) -> String? pub struct XmlStartTagScanner { // private fields diff --git a/ooxml/read_parse.mbt b/ooxml/read_parse.mbt index a2a2a129..22eb5950 100644 --- a/ooxml/read_parse.mbt +++ b/ooxml/read_parse.mbt @@ -15,10 +15,10 @@ fn is_attr_space(ch : UInt16) -> Bool { ///| /// Extract the value of attribute `name` from an XML start-tag body, or `None` /// if absent. Raises `InvalidXml` on a malformed (unterminated) attribute. -pub fn attr_value( +pub fn attr_value_view( tag : StringView, name : StringView, -) -> String? raise ParseXmlError { +) -> StringView? raise ParseXmlError { let tag_len = tag.length() let name_len = name.length() let mut i = 0 @@ -70,8 +70,7 @@ pub fn attr_value( } } if matched { - let value = tag.to_owned().unsafe_substring(start=value_start, end=i) - return Some(value.to_string()) + return Some(tag[value_start:i]) } } i = i + 1 @@ -79,6 +78,20 @@ pub fn attr_value( None } +///| +/// Extracts and owns an XML attribute value. Parsers that only compare or +/// validate an attribute should prefer `attr_value_view` so a large start tag +/// is not copied before its limits are checked. +pub fn attr_value( + tag : StringView, + name : StringView, +) -> String? raise ParseXmlError { + match attr_value_view(tag, name) { + Some(value) => Some(value.to_owned()) + None => None + } +} + ///| let package_relationships_namespace = "http://schemas.openxmlformats.org/package/2006/relationships" diff --git a/ooxml/relationships.mbt b/ooxml/relationships.mbt index 710d8bde..ecca828b 100644 --- a/ooxml/relationships.mbt +++ b/ooxml/relationships.mbt @@ -32,22 +32,30 @@ fn Relationships::add_auto( ///| fn Relationships::to_xml(self : Relationships) -> String { - let sb = StringBuilder::new() + self.to_xml_with_limit(None).unwrap() +} + +///| +fn Relationships::to_xml_with_limit( + self : Relationships, + maximum : Int?, +) -> String? { + let sb = XmlOutputBuilder::new(maximum) sb.write_view("\n") sb.write_view( "\n", ) for item in self.items { sb.write_view(" { sb.write_view("\" TargetMode=\"") - sb.write_view(escape_xml_attr(mode)) + sb.write_attr(mode) sb.write_view("\"/>") } None => sb.write_view("\"/>") @@ -55,7 +63,7 @@ fn Relationships::to_xml(self : Relationships) -> String { sb.write_view("\n") } sb.write_view("") - sb.to_string() + sb.finish() } ///| diff --git a/ooxml/xml.mbt b/ooxml/xml.mbt index d9498d3f..1d7956d8 100644 --- a/ooxml/xml.mbt +++ b/ooxml/xml.mbt @@ -14,6 +14,106 @@ pub fn xml_needs_escape(value : StringView, attr~ : Bool) -> Bool { false } +///| +/// Package-local UTF-8-counted XML output used by OOXML manifests. A limited +/// writer stops before appending the fragment that would cross its ceiling, so +/// callers can reject oversized generated metadata without first retaining it. +priv struct XmlOutputBuilder { + builder : StringBuilder + maximum : Int? + mut encoded_bytes : Int + mut exceeded : Bool +} + +///| +fn XmlOutputBuilder::new(maximum : Int?) -> XmlOutputBuilder { + { builder: StringBuilder::new(), maximum, encoded_bytes: 0, exceeded: false } +} + +///| +fn xml_output_utf8_length_within(value : StringView, maximum : Int) -> Int? { + let mut length = 0 + for character in value { + let scalar = character.to_int() + let width = if scalar <= 0x7F { + 1 + } else if scalar <= 0x7FF { + 2 + } else if scalar <= 0xFFFF { + 3 + } else { + 4 + } + if width > maximum - length { + return None + } + length = length + width + } + Some(length) +} + +///| +fn XmlOutputBuilder::write_view( + self : XmlOutputBuilder, + value : StringView, +) -> Unit { + if self.exceeded { + return + } + match self.maximum { + Some(maximum) => { + let remaining = maximum - self.encoded_bytes + match xml_output_utf8_length_within(value, remaining) { + Some(length) => { + self.builder.write_view(value) + self.encoded_bytes = self.encoded_bytes + length + } + None => self.exceeded = true + } + } + None => self.builder.write_view(value) + } +} + +///| +fn XmlOutputBuilder::write_attr( + self : XmlOutputBuilder, + value : StringView, +) -> Unit { + let mut literal_start = 0 + for index in 0.. Some("&") + unit if unit == ('<' : UInt16) => Some("<") + unit if unit == ('>' : UInt16) => Some(">") + unit if unit == ('"' : UInt16) => Some(""") + unit if unit == ('\'' : UInt16) => Some("'") + _ => None + } + match escaped { + Some(entity) => { + self.write_view(value[literal_start:index]) + self.write_view(entity) + literal_start = index + 1 + } + None => () + } + if self.exceeded { + return + } + } + self.write_view(value[literal_start:]) +} + +///| +fn XmlOutputBuilder::finish(self : XmlOutputBuilder) -> String? { + if self.exceeded { + None + } else { + Some(self.builder.to_string()) + } +} + ///| /// Escape XML text content (`&`, `<`, `>`). Returns the input unchanged when no /// escaping is needed (a view over a whole string converts back without @@ -23,14 +123,24 @@ pub fn escape_xml_text(value : StringView) -> String { return value.to_owned() } let sb = StringBuilder::new() - for c in value { - match c { - '&' => sb.write_view("&") - '<' => sb.write_view("<") - '>' => sb.write_view(">") - _ => sb.write_char(c) + let mut literal_start = 0 + for index in 0.. Some("&") + unit if unit == ('<' : UInt16) => Some("<") + unit if unit == ('>' : UInt16) => Some(">") + _ => None + } + match escaped { + Some(entity) => { + sb.write_view(value[literal_start:index]) + sb.write_view(entity) + literal_start = index + 1 + } + None => () } } + sb.write_view(value[literal_start:]) sb.to_string() } @@ -42,15 +152,25 @@ pub fn escape_xml_attr(value : StringView) -> String { return value.to_owned() } let sb = StringBuilder::new() - for c in value { - match c { - '&' => sb.write_view("&") - '<' => sb.write_view("<") - '>' => sb.write_view(">") - '"' => sb.write_view(""") - '\'' => sb.write_view("'") - _ => sb.write_char(c) + let mut literal_start = 0 + for index in 0.. Some("&") + unit if unit == ('<' : UInt16) => Some("<") + unit if unit == ('>' : UInt16) => Some(">") + unit if unit == ('"' : UInt16) => Some(""") + unit if unit == ('\'' : UInt16) => Some("'") + _ => None + } + match escaped { + Some(entity) => { + sb.write_view(value[literal_start:index]) + sb.write_view(entity) + literal_start = index + 1 + } + None => () } } + sb.write_view(value[literal_start:]) sb.to_string() } diff --git a/ooxml/xml_test.mbt b/ooxml/xml_test.mbt index 8ab2e536..fc1a372c 100644 --- a/ooxml/xml_test.mbt +++ b/ooxml/xml_test.mbt @@ -54,3 +54,14 @@ test "xml_needs_escape distinguishes text and attribute contexts" { assert_false(@ooxml.xml_needs_escape("clean", attr=true)) assert_false(@ooxml.xml_needs_escape("", attr=true)) } + +///| +test "escaping handles megabyte-safe spans without per-character output" { + let span = "a".repeat(1024 * 1024) + let text = @ooxml.escape_xml_text(span + "&tail") + assert_eq(text.length(), span.length() + "&tail".length()) + assert_true(text.has_suffix("&tail")) + let attr = @ooxml.escape_xml_attr(span + "\"tail") + assert_eq(attr.length(), span.length() + ""tail".length()) + assert_true(attr.has_suffix(""tail")) +} diff --git a/xlsx/calc_financial_edge_test.mbt b/xlsx/calc_financial_edge_test.mbt index 6d8f18dd..be76727f 100644 --- a/xlsx/calc_financial_edge_test.mbt +++ b/xlsx/calc_financial_edge_test.mbt @@ -33,7 +33,9 @@ fn calc_summary( /// engine matches the documented figures except DURATION/MDURATION /// (5.9920/5.7339 vs Excel's documented 5.9938/5.7357) and the switching /// VDB case (396.31 vs Excel's documented 393.80) — kept as snapshots of -/// current behavior. +/// current behavior. ODDFYIELD uses an iterative solver whose final IEEE-754 +/// digit varies across backends, so that case uses the shared numeric tolerance +/// while every stable result remains in the strict snapshot. test "calc financial securities happy paths" { inspect( calc_summary([ @@ -41,7 +43,6 @@ test "calc financial securities happy paths" { "PRICEMAT(DATE(2008,2,15),DATE(2008,4,13),DATE(2007,11,11),0.061,0.061,0)", "YIELDMAT(DATE(2008,3,15),DATE(2008,11,3),DATE(2007,11,8),0.0625,100.0123,0)", "ODDFPRICE(DATE(2008,11,11),DATE(2021,3,1),DATE(2008,10,15),DATE(2009,3,1),0.0785,0.0625,100,2,1)", - "ODDFYIELD(DATE(2008,11,11),DATE(2021,3,1),DATE(2008,10,15),DATE(2009,3,1),0.0575,84.5,100,2,0)", "ODDLPRICE(DATE(2008,2,7),DATE(2008,6,15),DATE(2007,10,15),0.0375,0.0405,100,2,0)", "ODDLYIELD(DATE(2008,4,20),DATE(2008,6,15),DATE(2007,12,24),0.0375,99.875,100,2,0)", "PRICEDISC(DATE(2008,2,16),DATE(2008,3,1),0.0525,100,2)", "YIELDDISC(DATE(2008,2,16),DATE(2008,3,1),99.795,100,2)", @@ -64,7 +65,6 @@ test "calc financial securities happy paths" { #|PRICEMAT(DATE(2008,2,15),DATE(2008,4,13),DATE(2007,11,11),0.061,0.061,0) => 99.984498875557 #|YIELDMAT(DATE(2008,3,15),DATE(2008,11,3),DATE(2007,11,8),0.0625,100.0123,0) => 0.0609543336915386 #|ODDFPRICE(DATE(2008,11,11),DATE(2021,3,1),DATE(2008,10,15),DATE(2009,3,1),0.0785,0.0625,100,2,1) => 113.597717474079 - #|ODDFYIELD(DATE(2008,11,11),DATE(2021,3,1),DATE(2008,10,15),DATE(2009,3,1),0.0575,84.5,100,2,0) => 0.0772455415978174 #|ODDLPRICE(DATE(2008,2,7),DATE(2008,6,15),DATE(2007,10,15),0.0375,0.0405,100,2,0) => 99.8782860147213 #|ODDLYIELD(DATE(2008,4,20),DATE(2008,6,15),DATE(2007,12,24),0.0375,99.875,100,2,0) => 0.0451922356291692 #|PRICEDISC(DATE(2008,2,16),DATE(2008,3,1),0.0525,100,2) => 99.7958333333333 @@ -96,6 +96,15 @@ test "calc financial securities happy paths" { #| ), ) + let workbook = @xlsx.Workbook::new() + let sheet = workbook.add_sheet("Sheet1") + sheet.set_cell_formula( + "A1", "ODDFYIELD(DATE(2008,11,11),DATE(2021,3,1),DATE(2008,10,15),DATE(2009,3,1),0.0575,84.5,100,2,0)", + ) + assert_calc_numeric_close( + workbook.calc_cell_value("Sheet1", "A1"), + "0.0772455415978174", + ) } ///| diff --git a/xlsx/doc_props.mbt b/xlsx/doc_props.mbt index aac8bf30..df48276d 100644 --- a/xlsx/doc_props.mbt +++ b/xlsx/doc_props.mbt @@ -189,8 +189,11 @@ fn bool_text(value : Bool) -> String { } ///| -fn write_core_properties_xml(props : CoreProperties) -> String { - let sb = StringBuilder::new() +fn write_core_properties_xml( + props : CoreProperties, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( " String { sb.write_view(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n") if props.title != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.title)) + sb.write_xml_text(props.title) sb.write_view("\n") } if props.subject != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.subject)) + sb.write_xml_text(props.subject) sb.write_view("\n") } if props.creator != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.creator)) + sb.write_xml_text(props.creator) sb.write_view("\n") } if props.keywords != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.keywords)) + sb.write_xml_text(props.keywords) sb.write_view("\n") } if props.description != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.description)) + sb.write_xml_text(props.description) sb.write_view("\n") } if props.last_modified_by != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.last_modified_by)) + sb.write_xml_text(props.last_modified_by) sb.write_view("\n") } if props.language != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.language)) + sb.write_xml_text(props.language) sb.write_view("\n") } if props.identifier != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.identifier)) + sb.write_xml_text(props.identifier) sb.write_view("\n") } if props.revision != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.revision)) + sb.write_xml_text(props.revision) sb.write_view("\n") } if props.content_status != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.content_status)) + sb.write_xml_text(props.content_status) sb.write_view("\n") } if props.category != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.category)) + sb.write_xml_text(props.category) sb.write_view("\n") } if props.version != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.version)) + sb.write_xml_text(props.version) sb.write_view("\n") } if props.created != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.created)) + sb.write_xml_text(props.created) sb.write_view("\n") } if props.modified != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.modified)) + sb.write_xml_text(props.modified) sb.write_view("\n") } sb.write_view("") @@ -274,8 +277,11 @@ fn write_core_properties_xml(props : CoreProperties) -> String { } ///| -fn write_app_properties_xml(props : AppProperties) -> String { - let sb = StringBuilder::new() +fn write_app_properties_xml( + props : AppProperties, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( " String { ) if props.application != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.application)) + sb.write_xml_text(props.application) sb.write_view("\n") } match props.scale_crop { @@ -306,7 +312,7 @@ fn write_app_properties_xml(props : AppProperties) -> String { } if props.company != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.company)) + sb.write_xml_text(props.company) sb.write_view("\n") } match props.links_up_to_date { @@ -327,7 +333,7 @@ fn write_app_properties_xml(props : AppProperties) -> String { } if props.app_version != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(props.app_version)) + sb.write_xml_text(props.app_version) sb.write_view("\n") } sb.write_view("") @@ -526,9 +532,9 @@ fn parse_custom_property_value( ///| fn write_custom_property_value( - sb : StringBuilder, + sb : LimitedXmlBuilder, value : CustomPropertyValue, -) -> Unit { +) -> Unit raise XlsxError { match value { Integer(number) => { sb.write_view("") @@ -547,20 +553,23 @@ fn write_custom_property_value( } Text(text) => { sb.write_view("") - sb.write_view(escape_xml_text(text)) + sb.write_xml_text(text) sb.write_view("") } DateTime(text) => { sb.write_view("") - sb.write_view(escape_xml_text(text)) + sb.write_xml_text(text) sb.write_view("") } } } ///| -fn write_custom_properties_xml(props : ArrayView[CustomProperty]) -> String { - let sb = StringBuilder::new() +fn write_custom_properties_xml( + props : ArrayView[CustomProperty], + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view(" String { sb.write_view(" ") write_custom_property_value(sb, value) sb.write_view("\n") diff --git a/xlsx/form_control.mbt b/xlsx/form_control.mbt index b422431f..ebff7da0 100644 --- a/xlsx/form_control.mbt +++ b/xlsx/form_control.mbt @@ -332,8 +332,9 @@ fn write_form_controls_vml( sheet_id : Int, sheet : Worksheet, controls : ArrayView[FormControl], + budget? : WritePartBudget, ) -> String raise XlsxError { - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -393,23 +394,43 @@ fn write_form_controls_vml( match preset { Some(p) => { match p.button { - Some(v) => sb.write_view(" o:button=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" o:button=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match p.filled { - Some(v) => sb.write_view(" filled=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" filled=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match p.fill_color { - Some(v) => sb.write_view(" fillcolor=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" fillcolor=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match p.stroked { - Some(v) => sb.write_view(" stroked=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" stroked=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match p.stroke_color { - Some(v) => sb.write_view(" strokecolor=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" strokecolor=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } } @@ -432,7 +453,7 @@ fn write_form_controls_vml( sb.write_view("
") if control.text != "" { sb.write_view("") - sb.write_view(escape_xml_text(control.text)) + sb.write_xml_text(control.text) sb.write_view("") } for run in control.paragraph { @@ -442,7 +463,7 @@ fn write_form_controls_vml( match font.family { Some(face) => { sb.write_view(" face=\"") - sb.write_view(escape_xml_attr(face)) + sb.write_xml_attr(face) sb.write_view("\"") } None => () @@ -464,7 +485,7 @@ fn write_form_controls_vml( "#\{color}" } sb.write_view(" color=\"") - sb.write_view(escape_xml_attr(normalized)) + sb.write_xml_attr(normalized) sb.write_view("\"") } None => () @@ -473,55 +494,73 @@ fn write_form_controls_vml( None => () } sb.write_view(">") - let mut content = escape_xml_text(run.text) + "

\r\n" - match run.font { - Some(font) => { - match font.underline { - Some("double") => content = "\{content}" - Some("single") => content = "\{content}" - Some(_) => () - None => () + let font = run.font + match font { + Some(value) => { + if value.bold { + sb.write_view("") } - if font.italic { - content = "\{content}" + if value.italic { + sb.write_view("") } - if font.bold { - content = "\{content}" + match value.underline { + Some("double") => sb.write_view("") + Some("single") => sb.write_view("") + _ => () + } + } + None => () + } + sb.write_xml_text(run.text) + sb.write_view("

\r\n") + match font { + Some(value) => { + match value.underline { + Some("double") | Some("single") => sb.write_view("
") + _ => () + } + if value.italic { + sb.write_view("
") + } + if value.bold { + sb.write_view("
") } } None => () } - sb.write_view(content) sb.write_view("") } sb.write_view("
\n") sb.write_view(" \n") } sb.write_view(" \n") sb.write_view(" \{anchor}\n") match preset { Some(p) => { match p.auto_fill { - Some(v) => - sb.write_view( - " \{escape_xml_text(v)}\n", - ) + Some(v) => { + sb.write_view(" ") + sb.write_xml_text(v) + sb.write_view("\n") + } None => () } match p.text_h_align { - Some(v) => - sb.write_view( - " \{escape_xml_text(v)}\n", - ) + Some(v) => { + sb.write_view(" ") + sb.write_xml_text(v) + sb.write_view("\n") + } None => () } match p.text_v_align { - Some(v) => - sb.write_view( - " \{escape_xml_text(v)}\n", - ) + Some(v) => { + sb.write_view(" ") + sb.write_xml_text(v) + sb.write_view("\n") + } None => () } if p.no_three_d { @@ -550,20 +589,20 @@ fn write_form_controls_vml( match control.macro_name { Some(name) => { sb.write_view(" ") - sb.write_view(escape_xml_text(name)) + sb.write_xml_text(name) sb.write_view("\n") } None => () } if control.text != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(control.text)) + sb.write_xml_text(control.text) sb.write_view("\n") } match control.cell_link { Some(link) => { sb.write_view(" ") - sb.write_view(escape_xml_text(link)) + sb.write_xml_text(link) sb.write_view("\n") } None => () diff --git a/xlsx/ignored_errors.mbt b/xlsx/ignored_errors.mbt index 3146e617..3769631f 100644 --- a/xlsx/ignored_errors.mbt +++ b/xlsx/ignored_errors.mbt @@ -54,13 +54,15 @@ fn ignored_error_from_type( } ///| -fn ignored_error_xml(error : IgnoredError) -> String raise XlsxError { +fn write_ignored_error_xml( + sb : LimitedXmlBuilder, + error : IgnoredError, +) -> Unit raise XlsxError { if error.sqref == "" { raise InvalidSheetOperation(msg="ignored errors sqref empty") } - let sb = StringBuilder::new() sb.write_view(" String raise XlsxError { sb.write_view(" calculatedColumn=\"\{bool_attr(true)}\"") } sb.write_view("/>") +} + +///| +fn ignored_error_xml(error : IgnoredError) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(None) + write_ignored_error_xml(sb, error) sb.to_string() } ///| fn write_ignored_errors_xml( errors : ArrayView[IgnoredError], + budget? : WritePartBudget, ) -> String? raise XlsxError { if errors.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") for error in errors { sb.write_view(" ") - sb.write_view(ignored_error_xml(error)) + write_ignored_error_xml(sb, error) sb.write_view("\n") } sb.write_view(" \n") diff --git a/xlsx/options.mbt b/xlsx/options.mbt index 69ece69d..04b4772f 100644 --- a/xlsx/options.mbt +++ b/xlsx/options.mbt @@ -17,6 +17,36 @@ let default_max_total_preserved_source_bytes : Int = default_max_package_bytes + ///| let default_max_xml_part_bytes : Int = 16 * 1024 * 1024 +///| +let default_max_total_xml_bytes : Int = 128 * 1024 * 1024 + +///| +let default_max_xml_markup_tokens : Int = 2_000_000 + +///| +let default_max_relationship_records : Int = 1_000_000 + +///| +let default_max_relationship_id_chars : Int = 1_024 + +///| +let default_max_relationship_type_chars : Int = 16 * 1_024 + +///| +let default_max_relationship_target_chars : Int = 64 * 1_024 + +///| +let default_max_total_relationship_chars : Int = 16 * 1_024 * 1_024 + +///| +let default_max_materialized_cells : Int = 1_000_000 + +///| +let default_max_materialized_row_column_dimensions : Int = 1_000_000 + +///| +let default_max_row_column_dimension_work : Int = 1_000_000 + ///| let default_max_kdf_iterations : Int = 1_000_000 @@ -34,9 +64,13 @@ let default_max_parser_work_units : Int = 512 * 1024 * 1024 /// /// The type is opaque so every public read path receives a validated, /// internally consistent policy. ZIP limits are enforced while inflating; -/// `max_xml_part_bytes` is enforced before any individual XML part is decoded. -/// Semantic ceilings independently bound workbook fan-out, XML-backed parser -/// items, derived materialization, and cumulative parser work. +/// `max_xml_part_bytes` is enforced before any individual XML part is decoded, +/// aggregate decoded XML, markup tokens, materialized worksheet cells, and +/// retained row/column dimensions are bounded across the complete workbook +/// parse. Semantic ceilings also bound workbook fan-out, XML-backed parser +/// items, derived materialization, and cumulative parser work. Row/column +/// dimension expansion work has a separate ceiling so overlapping declarations +/// cannot hide excessive map-update work behind the retained-item limit; /// `max_kdf_iterations` bounds encrypted-package password derivation. pub struct ReadLimits { priv max_package_bytes : Int @@ -45,6 +79,16 @@ pub struct ReadLimits { priv max_total_uncompressed_bytes : Int priv max_total_preserved_source_bytes : Int priv max_xml_part_bytes : Int + priv max_total_xml_bytes : Int + priv max_xml_markup_tokens : Int + priv max_relationship_records : Int + priv max_relationship_id_chars : Int + priv max_relationship_type_chars : Int + priv max_relationship_target_chars : Int + priv max_total_relationship_chars : Int + priv max_materialized_cells : Int + priv max_materialized_row_column_dimensions : Int + priv max_row_column_dimension_work : Int priv max_kdf_iterations : Int priv max_workbook_sheets : Int priv max_parser_items : Int @@ -54,8 +98,12 @@ pub struct ReadLimits { ///| /// Returns the production XLSX read policy: 128 MiB source packages, 8,192 /// entries, 64 MiB per entry, 256 MiB aggregate expansion, 16 MiB per XML part, -/// 1,024 sheets, two million parser items, 512 Mi parser work units, and one -/// million Agile KDF iterations. Call `with_values` for stricter limits. +/// 128 MiB decoded XML, two million markup tokens, one million relationship +/// records with 16 MiB retained relationship text, one million materialized +/// cells, one million retained row/column dimensions, one million row/column +/// dimension expansion steps, 1,024 sheets, two million parser items, 512 Mi +/// parser work units, and one million Agile KDF iterations. Call `with_values` +/// for stricter limits. pub fn ReadLimits::new() -> ReadLimits { { max_package_bytes: default_max_package_bytes, @@ -64,6 +112,16 @@ pub fn ReadLimits::new() -> ReadLimits { max_total_uncompressed_bytes: default_max_total_uncompressed_bytes, max_total_preserved_source_bytes: default_max_total_preserved_source_bytes, max_xml_part_bytes: default_max_xml_part_bytes, + max_total_xml_bytes: default_max_total_xml_bytes, + max_xml_markup_tokens: default_max_xml_markup_tokens, + max_relationship_records: default_max_relationship_records, + max_relationship_id_chars: default_max_relationship_id_chars, + max_relationship_type_chars: default_max_relationship_type_chars, + max_relationship_target_chars: default_max_relationship_target_chars, + max_total_relationship_chars: default_max_total_relationship_chars, + max_materialized_cells: default_max_materialized_cells, + max_materialized_row_column_dimensions: default_max_materialized_row_column_dimensions, + max_row_column_dimension_work: default_max_row_column_dimension_work, max_kdf_iterations: default_max_kdf_iterations, max_workbook_sheets: default_max_workbook_sheets, max_parser_items: default_max_parser_items, @@ -81,6 +139,16 @@ pub fn ReadLimits::with_values( max_total_uncompressed_bytes? : Int = default_max_total_uncompressed_bytes, max_total_preserved_source_bytes? : Int = default_max_total_preserved_source_bytes, max_xml_part_bytes? : Int = default_max_xml_part_bytes, + max_total_xml_bytes? : Int = default_max_total_xml_bytes, + max_xml_markup_tokens? : Int = default_max_xml_markup_tokens, + max_relationship_records? : Int = default_max_relationship_records, + max_relationship_id_chars? : Int = default_max_relationship_id_chars, + max_relationship_type_chars? : Int = default_max_relationship_type_chars, + max_relationship_target_chars? : Int = default_max_relationship_target_chars, + max_total_relationship_chars? : Int = default_max_total_relationship_chars, + max_materialized_cells? : Int = default_max_materialized_cells, + max_materialized_row_column_dimensions? : Int = default_max_materialized_row_column_dimensions, + max_row_column_dimension_work? : Int = default_max_row_column_dimension_work, max_kdf_iterations? : Int = default_max_kdf_iterations, max_workbook_sheets? : Int = default_max_workbook_sheets, max_parser_items? : Int = default_max_parser_items, @@ -92,6 +160,16 @@ pub fn ReadLimits::with_values( max_total_uncompressed_bytes <= 0 || max_total_preserved_source_bytes <= 0 || max_xml_part_bytes <= 0 || + max_total_xml_bytes <= 0 || + max_xml_markup_tokens <= 0 || + max_relationship_records <= 0 || + max_relationship_id_chars <= 0 || + max_relationship_type_chars <= 0 || + max_relationship_target_chars <= 0 || + max_total_relationship_chars <= 0 || + max_materialized_cells <= 0 || + max_materialized_row_column_dimensions <= 0 || + max_row_column_dimension_work <= 0 || max_kdf_iterations <= 0 || max_workbook_sheets <= 0 || max_parser_items <= 0 || @@ -113,6 +191,16 @@ pub fn ReadLimits::with_values( max_total_uncompressed_bytes, max_total_preserved_source_bytes, max_xml_part_bytes, + max_total_xml_bytes, + max_xml_markup_tokens, + max_relationship_records, + max_relationship_id_chars, + max_relationship_type_chars, + max_relationship_target_chars, + max_total_relationship_chars, + max_materialized_cells, + max_materialized_row_column_dimensions, + max_row_column_dimension_work, max_kdf_iterations, max_workbook_sheets, max_parser_items, @@ -157,6 +245,68 @@ pub fn ReadLimits::max_xml_part_bytes(self : ReadLimits) -> Int { self.max_xml_part_bytes } +///| +/// Returns the maximum aggregate XML-like bytes decoded during one read. +pub fn ReadLimits::max_total_xml_bytes(self : ReadLimits) -> Int { + self.max_total_xml_bytes +} + +///| +/// Returns the maximum aggregate markup-token starts scanned while decoding. +pub fn ReadLimits::max_xml_markup_tokens(self : ReadLimits) -> Int { + self.max_xml_markup_tokens +} + +///| +/// Returns the maximum aggregate relationship records scanned in one package. +pub fn ReadLimits::max_relationship_records(self : ReadLimits) -> Int { + self.max_relationship_records +} + +///| +/// Returns the maximum retained relationship id length in characters. +pub fn ReadLimits::max_relationship_id_chars(self : ReadLimits) -> Int { + self.max_relationship_id_chars +} + +///| +/// Returns the maximum relationship type URI length in characters. +pub fn ReadLimits::max_relationship_type_chars(self : ReadLimits) -> Int { + self.max_relationship_type_chars +} + +///| +/// Returns the maximum internal/external relationship target length. +pub fn ReadLimits::max_relationship_target_chars(self : ReadLimits) -> Int { + self.max_relationship_target_chars +} + +///| +/// Returns the maximum aggregate relationship attribute characters scanned. +pub fn ReadLimits::max_total_relationship_chars(self : ReadLimits) -> Int { + self.max_total_relationship_chars +} + +///| +/// Returns the maximum worksheet cells materialized across the workbook. +pub fn ReadLimits::max_materialized_cells(self : ReadLimits) -> Int { + self.max_materialized_cells +} + +///| +/// Returns the maximum retained row/column dimensions across the workbook. +pub fn ReadLimits::max_materialized_row_column_dimensions( + self : ReadLimits, +) -> Int { + self.max_materialized_row_column_dimensions +} + +///| +/// Returns the maximum row/column dimension expansion work across the workbook. +pub fn ReadLimits::max_row_column_dimension_work(self : ReadLimits) -> Int { + self.max_row_column_dimension_work +} + ///| /// Returns the maximum accepted Agile password-KDF work factor. pub fn ReadLimits::max_kdf_iterations(self : ReadLimits) -> Int { @@ -183,6 +333,67 @@ pub fn ReadLimits::max_parser_work_units(self : ReadLimits) -> Int { self.max_parser_work_units } +///| +/// A fail-closed resource policy for XLSX serialization. The archive ceilings +/// are enforced before each generated part is retained; multi-stage VML +/// composition also debits every simultaneously retained intermediate from the +/// remaining aggregate allowance. `max_output_bytes` is then enforced by ZIP's +/// storage-free sizing pass before package allocation. +pub struct WriteLimits { + priv max_output_bytes : Int + priv max_archive_entries : Int + priv max_entry_uncompressed_bytes : Int + priv max_total_uncompressed_bytes : Int +} + +///| +/// Builds a validated XLSX write policy. All ceilings must be positive and the +/// per-entry expansion ceiling cannot exceed the aggregate archive ceiling. +pub fn WriteLimits::with_values( + max_output_bytes~ : Int, + max_archive_entries~ : Int, + max_entry_uncompressed_bytes~ : Int, + max_total_uncompressed_bytes~ : Int, +) -> WriteLimits raise XlsxError { + if max_output_bytes <= 0 || + max_archive_entries <= 0 || + max_entry_uncompressed_bytes <= 0 || + max_total_uncompressed_bytes <= 0 { + raise InvalidOptions(msg="XLSX write limits must be positive") + } + if max_entry_uncompressed_bytes > max_total_uncompressed_bytes { + raise InvalidOptions( + msg="XLSX write entry limit exceeds aggregate uncompressed limit", + ) + } + { + max_output_bytes, + max_archive_entries, + max_entry_uncompressed_bytes, + max_total_uncompressed_bytes, + } +} + +///| +pub fn WriteLimits::max_output_bytes(self : WriteLimits) -> Int { + self.max_output_bytes +} + +///| +pub fn WriteLimits::max_archive_entries(self : WriteLimits) -> Int { + self.max_archive_entries +} + +///| +pub fn WriteLimits::max_entry_uncompressed_bytes(self : WriteLimits) -> Int { + self.max_entry_uncompressed_bytes +} + +///| +pub fn WriteLimits::max_total_uncompressed_bytes(self : WriteLimits) -> Int { + self.max_total_uncompressed_bytes +} + ///| pub struct Options { max_calc_iterations : UInt diff --git a/xlsx/options_test.mbt b/xlsx/options_test.mbt index 6d4ae129..da99cbcb 100644 --- a/xlsx/options_test.mbt +++ b/xlsx/options_test.mbt @@ -115,6 +115,26 @@ test "read limits reject non-positive semantic ceilings" { } } +///| +test "read limits reject non-positive row and column dimension work" { + let result : Result[@xlsx.ReadLimits, Error] = Ok( + @xlsx.ReadLimits::with_values(max_row_column_dimension_work=0), + ) catch { + error => Err(error) + } + inspect(result is Err(@xlsx.InvalidOptions(_)), content="true") +} + +///| +test "read limits reject non-positive relationship ceilings" { + let result : Result[@xlsx.ReadLimits, Error] = Ok( + @xlsx.ReadLimits::with_values(max_relationship_records=0), + ) catch { + error => Err(error) + } + inspect(result is Err(@xlsx.InvalidOptions(_)), content="true") +} + ///| test "default read limits expose the complete ZIP policy" { let limits = @xlsx.ReadLimits::new() @@ -127,8 +147,43 @@ test "default read limits expose the complete ZIP policy" { 128 * 1024 * 1024 + 65_535, ) assert_eq(limits.max_xml_part_bytes(), 16 * 1024 * 1024) + assert_eq(limits.max_total_xml_bytes(), 128 * 1024 * 1024) + assert_eq(limits.max_xml_markup_tokens(), 2_000_000) + assert_eq(limits.max_relationship_records(), 1_000_000) + assert_eq(limits.max_relationship_id_chars(), 1_024) + assert_eq(limits.max_relationship_type_chars(), 16 * 1_024) + assert_eq(limits.max_relationship_target_chars(), 64 * 1_024) + assert_eq(limits.max_total_relationship_chars(), 16 * 1_024 * 1_024) + assert_eq(limits.max_materialized_cells(), 1_000_000) + assert_eq(limits.max_materialized_row_column_dimensions(), 1_000_000) + assert_eq(limits.max_row_column_dimension_work(), 1_000_000) assert_eq(limits.max_kdf_iterations(), 1_000_000) assert_eq(limits.max_workbook_sheets(), 1024) assert_eq(limits.max_parser_items(), 2_000_000) assert_eq(limits.max_parser_work_units(), 512 * 1024 * 1024) } + +///| +test "write limits validate construction ceilings" { + let valid = @xlsx.WriteLimits::with_values( + max_output_bytes=1024, + max_archive_entries=8, + max_entry_uncompressed_bytes=256, + max_total_uncompressed_bytes=512, + ) + assert_eq(valid.max_output_bytes(), 1024) + assert_eq(valid.max_archive_entries(), 8) + assert_eq(valid.max_entry_uncompressed_bytes(), 256) + assert_eq(valid.max_total_uncompressed_bytes(), 512) + let invalid : Result[@xlsx.WriteLimits, Error] = Ok( + @xlsx.WriteLimits::with_values( + max_output_bytes=1024, + max_archive_entries=8, + max_entry_uncompressed_bytes=513, + max_total_uncompressed_bytes=512, + ), + ) catch { + error => Err(error) + } + inspect(invalid is Err(@xlsx.InvalidOptions(_)), content="true") +} diff --git a/xlsx/package_validation.mbt b/xlsx/package_validation.mbt index 7fbe03f2..bbbb4562 100644 --- a/xlsx/package_validation.mbt +++ b/xlsx/package_validation.mbt @@ -1,3 +1,137 @@ +///| +let max_package_validation_findings = 256 + +///| +let package_validation_omission_message = "additional package findings were omitted after the bounded validator limit" + +///| +fn push_package_problem(problems : Array[String], problem : String) -> Bool { + if problems.length() < max_package_validation_findings - 1 { + problems.push(problem) + true + } else { + if problems.length() == max_package_validation_findings - 1 { + problems.push(package_validation_omission_message) + } + false + } +} + +///| +fn package_content_type_is_xml(value : StringView) -> Bool { + let lower = value.to_owned().to_lower() + let media_type = match lower.find(";") { + Some(index) => lower[:index].trim() + None => lower.trim() + } + media_type == "application/xml" || + media_type == "text/xml" || + media_type.has_suffix("+xml") +} + +///| +fn package_validation_content_types( + archive : @zip.Archive, +) -> @ooxml.PackageContentTypes? { + match archive.get("[Content_Types].xml") { + Some(data) => + Some(@ooxml.parse_package_content_types(decode_package_part(data))) catch { + _ => None + } + None => None + } +} + +///| +fn charge_package_validation_xml_part( + data : BytesView, + limits : ReadLimits, + total_xml_bytes : Ref[Int], + markup_tokens : Ref[Int], +) -> Unit raise XlsxError { + if data.length() > limits.max_xml_part_bytes { + raise ResourceLimitExceeded( + kind="xml_part_bytes", + limit=limits.max_xml_part_bytes, + actual=data.length(), + ) + } + if data.length() > limits.max_total_xml_bytes - total_xml_bytes.val { + raise ResourceLimitExceeded( + kind="total_xml_bytes", + limit=limits.max_total_xml_bytes, + actual=bounded_actual_above_limit(limits.max_total_xml_bytes), + ) + } + total_xml_bytes.val = total_xml_bytes.val + data.length() + for byte in data { + if byte == b'<' { + if markup_tokens.val >= limits.max_xml_markup_tokens { + raise ResourceLimitExceeded( + kind="xml_markup_tokens", + limit=limits.max_xml_markup_tokens, + actual=bounded_actual_above_limit(limits.max_xml_markup_tokens), + ) + } + markup_tokens.val = markup_tokens.val + 1 + } + } +} + +///| +/// Applies the same aggregate XML-byte and markup-token policy as the complete +/// workbook reader before package validation starts decoding or scanning +/// package plumbing. XML identity follows the package content-type manifest, +/// with conventional XML-like suffixes retained as a fail-closed fallback. +/// Counting every XML package part makes the validation boundary deterministic +/// and prevents many individually-small parts from bypassing the aggregate read +/// policy. +fn enforce_package_validation_xml_limits( + archive : @zip.Archive, + limits : ReadLimits, +) -> Unit raise XlsxError { + let total_xml_bytes = Ref(0) + let markup_tokens = Ref(0) + // Enforce suffix-identifiable XML first. This includes the content-types + // manifest itself, so its byte and scan-work ceilings are charged before the + // manifest parser is allowed to inspect it. + for entry in archive.entries() { + if is_xml_package_part(entry.name()) { + charge_package_validation_xml_part( + entry.data(), + limits, + total_xml_bytes, + markup_tokens, + ) + } + } + let content_types = package_validation_content_types(archive) + for entry in archive.entries() { + if is_xml_package_part(entry.name()) { + continue + } + let declared_xml = match content_types { + Some(index) => { + let logical_name = logical_archive_part_path(entry.name()) + let declared = index.content_type_for(logical_name) catch { _ => None } + match declared { + Some(value) => package_content_type_is_xml(value) + None => false + } + } + None => false + } + if declared_xml { + charge_package_validation_xml_part( + entry.data(), + limits, + total_xml_bytes, + markup_tokens, + ) + } + } +} + ///| /// Checks the structural invariants an OOXML (xlsx) package must satisfy /// for Microsoft Excel to open it without a repair prompt, returning the @@ -18,7 +152,9 @@ /// This is a fast, dependency-free complement to the Microsoft OpenXML /// SDK validator: it runs entirely in MoonBit, so it can be asserted on /// every workbook a test generates. An unreadable ZIP is returned as a stable -/// finding; resource-policy violations raise `ResourceLimitExceeded`. +/// finding; resource-policy violations raise `ResourceLimitExceeded`. Findings +/// are capped at 256 while scanning, with a final omission marker when more +/// defects exist, so diagnostics cannot become an attacker-sized second tree. pub fn validate_ooxml_package( bytes : BytesView, limits? : ReadLimits = ReadLimits::new(), @@ -42,26 +178,42 @@ pub fn validate_ooxml_bounded_archive( limits? : ReadLimits = ReadLimits::new(), ) -> Array[String] raise XlsxError { require_bounded_archive(archive, limits) + enforce_package_validation_xml_limits(archive, limits) + preflight_archive_relationship_limits(archive, limits) validate_ooxml_archive(archive) } ///| -fn validate_ooxml_archive(archive : @zip.Archive) -> Array[String] { +fn validate_ooxml_archive( + archive : @zip.Archive, +) -> Array[String] raise XlsxError { let problems : Array[String] = [] let parts : Map[String, Int] = Map([]) for entry in archive.entries() { let name = entry.name() parts[name] = parts.get(name).unwrap_or(0) + 1 if name.contains("\\") { - problems.push("part name uses a backslash: \{name}") + if !push_package_problem(problems, "part name uses a backslash: \{name}") { + return problems + } } if name.has_prefix("/") { - problems.push("part name has a leading slash: \{name}") + if !push_package_problem( + problems, + "part name has a leading slash: \{name}", + ) { + return problems + } } } for name, count in parts { if count > 1 { - problems.push("duplicate part: \{name} (\{count} entries)") + if !push_package_problem( + problems, + "duplicate part: \{name} (\{count} entries)", + ) { + return problems + } } } @@ -71,7 +223,9 @@ fn validate_ooxml_archive(archive : @zip.Archive) -> Array[String] { "[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels", ] { if !parts.contains(required) { - problems.push("missing required part: \{required}") + if !push_package_problem(problems, "missing required part: \{required}") { + return problems + } } } @@ -91,7 +245,12 @@ fn validate_ooxml_archive(archive : @zip.Archive) -> Array[String] { None => false } if !has_override && !has_default { - problems.push("part has no declared content type: \{name}") + if !push_package_problem( + problems, + "part has no declared content type: \{name}", + ) { + return problems + } } } } @@ -101,22 +260,48 @@ fn validate_ooxml_archive(archive : @zip.Archive) -> Array[String] { // relationship target integrity for entry in archive.entries() { let name = entry.name() - if !name.has_suffix(".rels") { + if !is_relationship_package_part(name) { continue } let base = rels_base_dir(name) let rels_xml = decode_package_part(entry.data()) - for rel in parse_rel_targets(rels_xml) { - let (target, external) = rel - if external { - continue + // Process one relationship at a time. The aggregate token preflight above + // bounds scan work, while avoiding a second target array proportional to + // attacker-controlled relationship count. + for_each_relationship_start_tag(rels_xml, scanner => { + let target_opt = relationship_scanner_attribute(scanner, "Target") catch { + _ => None } - let resolved = resolve_part_path(base, target) - if resolved != "" && !parts.contains(resolved) { - problems.push( - "relationship in \{name} points to a missing part: \{target} -> \{resolved}", - ) + match target_opt { + Some(target) => { + let mode_opt = relationship_scanner_attribute(scanner, "TargetMode") catch { + _ => None + } + let external = match mode_opt { + Some(mode) => mode == "External" + None => false + } + if !external { + let resolved = resolve_part_path(base, target) + if resolved != "" && !parts.contains(resolved) { + let source_text = package_diagnostic_excerpt(name, 160) + let target_text = package_diagnostic_excerpt(target, 160) + let resolved_text = package_diagnostic_excerpt(resolved, 160) + if !push_package_problem( + problems, + "relationship in \{source_text} points to a missing part: \{target_text} -> \{resolved_text}", + ) { + return false + } + } + } + } + None => () } + true + }) + if problems.length() == max_package_validation_findings { + return problems } } problems @@ -161,12 +346,11 @@ fn parse_content_types(xml : String) -> (Map[String, Bool], Map[String, Bool]) { first = false continue } - let text = chunk.to_owned() - let end = match text.find(">") { + let end = match chunk.find(">") { Some(pos) => pos None => continue } - let ext = attr_value(text[:end], "Extension") catch { _ => None } + let ext = attr_value(chunk[:end], "Extension") catch { _ => None } match ext { Some(ext) => defaults[ext.to_lower()] = true None => () @@ -178,12 +362,11 @@ fn parse_content_types(xml : String) -> (Map[String, Bool], Map[String, Bool]) { first = false continue } - let text = chunk.to_owned() - let end = match text.find(">") { + let end = match chunk.find(">") { Some(pos) => pos None => continue } - let part = attr_value(text[:end], "PartName") catch { _ => None } + let part = attr_value(chunk[:end], "PartName") catch { _ => None } match part { Some(part) => overrides[part] = true None => () @@ -192,38 +375,6 @@ fn parse_content_types(xml : String) -> (Map[String, Bool], Map[String, Bool]) { (defaults, overrides) } -///| -/// Parses the relationship targets of a `.rels` part, returning each -/// (target, is_external) pair. -fn parse_rel_targets(xml : String) -> Array[(String, Bool)] { - let result : Array[(String, Bool)] = [] - let mut first = true - for chunk in xml.split("") { - Some(pos) => pos - None => continue - } - let tag = text[:end] - let target_opt = attr_value(tag, "Target") catch { _ => None } - let target = match target_opt { - Some(value) => value - None => continue - } - let mode_opt = attr_value(tag, "TargetMode") catch { _ => None } - let external = match mode_opt { - Some(mode) => mode == "External" - None => false - } - result.push((target, external)) - } - result -} - ///| /// The base directory a relationship target in `/_rels/.rels` /// resolves against: the directory containing the described part. For @@ -240,28 +391,48 @@ fn rels_base_dir(rels_part : String) -> String { /// leading-slash absolute form and `.`/`..` segments. Returns "" when /// the path escapes the package root (which the caller treats as /// unresolvable rather than a definite error). -fn resolve_part_path(base : String, target : String) -> String { - let combined = if target.has_prefix("/") { - target[1:].to_owned() +fn package_diagnostic_excerpt(value : StringView, maximum : Int) -> String { + if value.length() <= maximum { + value.to_owned() } else { - base + target + value[:maximum].to_owned() + "…" } - let segments : Array[String] = [] - for segment in combined.split("/") { - let seg = segment.to_owned() - if seg == "" || seg == "." { - continue - } - if seg == ".." { - if segments.length() == 0 { - return "" +} + +///| +fn resolve_part_path(base : StringView, target : StringView) -> String { + let segments : Array[StringView] = [] + fn consume(value : StringView) -> Bool { + for segment in value.split("/") { + if segment == "" || segment == "." { + continue } - segments.remove(segments.length() - 1) |> ignore - continue + if segment == ".." { + if segments.length() == 0 { + return false + } + ignore(segments.pop()) + } else { + segments.push(segment) + } + } + true + } + if target.has_prefix("/") { + if !consume(target[1:]) { + return "" + } + } else if !consume(base) || !consume(target) { + return "" + } + let result = StringBuilder::new() + for index, segment in segments { + if index > 0 { + result.write_char('/') } - segments.push(seg) + result.write_view(segment) } - segments.join("/") + result.to_string() } ///| diff --git a/xlsx/package_validation_test.mbt b/xlsx/package_validation_test.mbt index 4a8a6eec..cac2ab3a 100644 --- a/xlsx/package_validation_test.mbt +++ b/xlsx/package_validation_test.mbt @@ -36,6 +36,36 @@ test "validate_ooxml_package flags a missing relationship target" { ) } +///| +test "OOXML validation decodes targets and ignores foreign relationship names" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let archive = @zip.read(@xlsx.write(workbook)) + let root_rels = match archive.get("_rels/.rels") { + Some(data) => + @encoding/utf8.decode(data) catch { + _ => fail("root relationships are not UTF-8") + } + None => fail("missing root relationships") + } + let namespaced = root_rels.replace_all( + old="", + new="", + ) + let injected = namespaced.replace_all( + old="", + new="", + ) + assert_true(archive.replace("_rels/.rels", @encoding/utf8.encode(injected))) + archive.add("custom/a&b.xml", @encoding/utf8.encode("")) + debug_inspect( + @xlsx.validate_ooxml_package(@zip.write(archive)), + content=( + #|[] + ), + ) +} + ///| test "validate_ooxml_package flags an undeclared content type" { let workbook = @xlsx.Workbook::new() @@ -140,3 +170,218 @@ test "OOXML validation applies XML limits before scanning relationships" { _ => fail("expected validation XML limit") } } + +///| +test "OOXML validation follows XML content types beyond file suffixes" { + let archive = @zip.Archive::new() + archive.add( + "[Content_Types].xml", + @encoding/utf8.encode( + ( + #| + #| + #| + #| + #| + #| + ), + ), + ) + archive.add( + "_rels/.rels", + @encoding/utf8.encode( + ( + #| + #| + #| + #| + ), + ), + ) + archive.add( + "xl/workbook.xml", + @encoding/utf8.encode( + ( + #| + ), + ), + ) + archive.add( + "xl/_rels/workbook.xml.rels", + @encoding/utf8.encode( + ( + #| + #| + #| + #| + ), + ), + ) + let oversized = @encoding/utf8.encode(" ".repeat(2048)) + archive.add("xl/worksheets/%E6%96%87.dat", oversized) + let limit = 1024 + let result : Result[Array[String], Error] = Ok( + @xlsx.validate_ooxml_package( + @zip.write(archive), + limits=@xlsx.ReadLimits::with_values(max_xml_part_bytes=limit), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit=actual_limit, actual~)) => { + inspect(kind, content="xml_part_bytes") + assert_eq(actual_limit, limit) + assert_eq(actual, oversized.length()) + } + _ => fail("expected logical XML validation limit") + } +} + +///| +test "OOXML validation enforces aggregate XML bytes" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let limits = @xlsx.ReadLimits::with_values(max_total_xml_bytes=1) + let result : Result[Array[String], Error] = Ok( + @xlsx.validate_ooxml_package(@xlsx.write(workbook), limits~), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="total_xml_bytes") + assert_eq(limit, 1) + assert_true(actual > limit) + } + _ => fail("expected aggregate validation XML limit") + } +} + +///| +test "OOXML validation enforces aggregate markup tokens" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let limits = @xlsx.ReadLimits::with_values(max_xml_markup_tokens=1) + let result : Result[Array[String], Error] = Ok( + @xlsx.validate_ooxml_package(@xlsx.write(workbook), limits~), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="xml_markup_tokens") + assert_eq(limit, 1) + assert_true(actual > limit) + } + _ => fail("expected aggregate validation markup-token limit") + } +} + +///| +test "OOXML validation bounds relationship findings and scan retention" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let archive = @zip.read(@xlsx.write(workbook)) + let rels = StringBuilder::new() + rels.write_string( + "" + + "", + ) + |> ignore + for index in 0..<400 { + rels.write_string( + "", + ) + |> ignore + } + rels.write_string("") |> ignore + assert_true( + archive.replace("_rels/.rels", @encoding/utf8.encode(rels.to_string())), + ) + let problems = @xlsx.validate_ooxml_package(@zip.write(archive)) + assert_eq(problems.length(), 256) + inspect( + problems[problems.length() - 1], + content="additional package findings were omitted after the bounded validator limit", + ) +} + +///| +test "OOXML validation rejects oversized relationship attributes before maps" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let limits = @xlsx.ReadLimits::with_values(max_relationship_target_chars=4) + let result : Result[Array[String], Error] = Ok( + @xlsx.validate_ooxml_package(@xlsx.write(workbook), limits~), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="relationship_target_chars") + assert_eq(limit, 4) + assert_true(actual > limit) + } + _ => fail("expected relationship target limit") + } +} + +///| +test "OOXML validation charges relationship records package-wide" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let limits = @xlsx.ReadLimits::with_values(max_relationship_records=1) + let result : Result[Array[String], Error] = Ok( + @xlsx.validate_ooxml_package(@xlsx.write(workbook), limits~), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="relationship_records") + assert_eq(limit, 1) + assert_eq(actual, 2) + } + _ => fail("expected aggregate relationship-record limit") + } +} + +///| +test "OOXML validation counts case-varied relationship parts with XML whitespace" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Sheet1")) + let source = @zip.read(@xlsx.write(workbook)) + let archive = @zip.Archive::new() + for entry in source.entries() { + if entry.name().has_suffix(".rels") { + let xml = @encoding/utf8.decode(entry.data()) catch { + _ => fail("relationship part is not UTF-8") + } + let name = entry.name() + let case_varied_name = name[:name.length() - 5].to_owned() + ".RELS" + archive.add( + case_varied_name, + @encoding/utf8.encode( + xml.replace_all(old=" Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="relationship_records") + assert_eq(limit, 1) + assert_eq(actual, 2) + } + _ => fail("expected whitespace-delimited relationship-record limit") + } +} diff --git a/xlsx/pkg.generated.mbti b/xlsx/pkg.generated.mbti index 40feeea7..b8629a41 100644 --- a/xlsx/pkg.generated.mbti +++ b/xlsx/pkg.generated.mbti @@ -57,6 +57,8 @@ pub fn validate_ooxml_package(BytesView, limits? : ReadLimits) -> Array[String] pub fn write(Workbook) -> Bytes raise XlsxError +pub fn write_limited(Workbook, WriteLimits) -> Bytes raise XlsxError + pub fn write_with_password(Workbook, String) -> Bytes raise XlsxError // Errors @@ -1224,15 +1226,25 @@ pub struct ReadLimits { pub fn ReadLimits::max_archive_entries(Self) -> Int pub fn ReadLimits::max_entry_uncompressed_bytes(Self) -> Int pub fn ReadLimits::max_kdf_iterations(Self) -> Int +pub fn ReadLimits::max_materialized_cells(Self) -> Int +pub fn ReadLimits::max_materialized_row_column_dimensions(Self) -> Int pub fn ReadLimits::max_package_bytes(Self) -> Int pub fn ReadLimits::max_parser_items(Self) -> Int pub fn ReadLimits::max_parser_work_units(Self) -> Int +pub fn ReadLimits::max_relationship_id_chars(Self) -> Int +pub fn ReadLimits::max_relationship_records(Self) -> Int +pub fn ReadLimits::max_relationship_target_chars(Self) -> Int +pub fn ReadLimits::max_relationship_type_chars(Self) -> Int +pub fn ReadLimits::max_row_column_dimension_work(Self) -> Int pub fn ReadLimits::max_total_preserved_source_bytes(Self) -> Int +pub fn ReadLimits::max_total_relationship_chars(Self) -> Int pub fn ReadLimits::max_total_uncompressed_bytes(Self) -> Int +pub fn ReadLimits::max_total_xml_bytes(Self) -> Int pub fn ReadLimits::max_workbook_sheets(Self) -> Int +pub fn ReadLimits::max_xml_markup_tokens(Self) -> Int pub fn ReadLimits::max_xml_part_bytes(Self) -> Int pub fn ReadLimits::new() -> Self -pub fn ReadLimits::with_values(max_package_bytes? : Int, max_archive_entries? : Int, max_entry_uncompressed_bytes? : Int, max_total_uncompressed_bytes? : Int, max_total_preserved_source_bytes? : Int, max_xml_part_bytes? : Int, max_kdf_iterations? : Int, max_workbook_sheets? : Int, max_parser_items? : Int, max_parser_work_units? : Int) -> Self raise XlsxError +pub fn ReadLimits::with_values(max_package_bytes? : Int, max_archive_entries? : Int, max_entry_uncompressed_bytes? : Int, max_total_uncompressed_bytes? : Int, max_total_preserved_source_bytes? : Int, max_xml_part_bytes? : Int, max_total_xml_bytes? : Int, max_xml_markup_tokens? : Int, max_relationship_records? : Int, max_relationship_id_chars? : Int, max_relationship_type_chars? : Int, max_relationship_target_chars? : Int, max_total_relationship_chars? : Int, max_materialized_cells? : Int, max_materialized_row_column_dimensions? : Int, max_row_column_dimension_work? : Int, max_kdf_iterations? : Int, max_workbook_sheets? : Int, max_parser_items? : Int, max_parser_work_units? : Int) -> Self raise XlsxError pub(all) struct RichTextFont { bold : Bool @@ -1920,6 +1932,8 @@ pub fn Workbook::group_sheets(Self, Array[String]) -> Unit raise XlsxError pub fn Workbook::insert_cols(Self, StringView, Int, Int, formula_limits? : SharedFormulaLimits, cancelled? : () -> Bool) -> Unit raise XlsxError pub fn Workbook::insert_page_break(Self, StringView, StringView) -> Unit raise XlsxError pub fn Workbook::insert_rows(Self, StringView, Int, Int, formula_limits? : SharedFormulaLimits, cancelled? : () -> Bool) -> Unit raise XlsxError +pub fn Workbook::materialized_cell_count(Self) -> Int +pub fn Workbook::materialized_row_column_dimension_count(Self) -> Int pub fn Workbook::merge_cell(Self, StringView, StringView, StringView) -> Unit raise XlsxError pub fn Workbook::merge_cells(Self, StringView, String) -> Unit raise XlsxError pub fn Workbook::move_sheet(Self, StringView, StringView) -> Unit raise XlsxError @@ -2218,6 +2232,15 @@ pub fn Worksheet::used_bounds_limited(Self, maximum_stored_cells~ : Int, cancell pub fn Worksheet::vml_drawing_hf_xml(Self) -> String? pub fn Worksheet::vml_drawing_xml(Self) -> String? +pub struct WriteLimits { + // private fields +} +pub fn WriteLimits::max_archive_entries(Self) -> Int +pub fn WriteLimits::max_entry_uncompressed_bytes(Self) -> Int +pub fn WriteLimits::max_output_bytes(Self) -> Int +pub fn WriteLimits::max_total_uncompressed_bytes(Self) -> Int +pub fn WriteLimits::with_values(max_output_bytes~ : Int, max_archive_entries~ : Int, max_entry_uncompressed_bytes~ : Int, max_total_uncompressed_bytes~ : Int) -> Self raise XlsxError + type X14DataBarProps derive(@debug.Debug) #deprecated pub fn X14DataBarProps::to_repr(Self) -> @debug.Repr diff --git a/xlsx/read.mbt b/xlsx/read.mbt index 0d0aa9aa..c41fbed5 100644 --- a/xlsx/read.mbt +++ b/xlsx/read.mbt @@ -1356,81 +1356,60 @@ fn parse_x14_conditional_formatting_blocks( xml : StringView, ) -> Array[String] raise XlsxError { let blocks : Array[String] = [] - let xml_str = xml.to_owned() - if xml_str.contains("") { + if xml.contains("") { raise InvalidXml(msg="x14:conditionalFormattings tag not closed") } - let mut open_pos : Int? = None - let mut open_search_from = 0 - let open_needle = " pos - None => break - } - let start = open_search_from + rel_start - let after_needle = start + open_needle.length() - let next_char = xml_str[after_needle:after_needle + 1] - if next_char == "s" { - open_search_from = after_needle - continue - } - open_pos = Some(start) - break - } - match xml_str.find("") { - Some(close_pos) => - match open_pos { - Some(pos) => - if pos > close_pos { - raise InvalidXml(msg="x14:conditionalFormatting close before open") - } - None => - raise InvalidXml(msg="x14:conditionalFormatting close without open") - } - None => () - } let needle = " + raise InvalidXml(msg="x14:conditionalFormatting close before open") + (Some(_), None) => + raise InvalidXml(msg="x14:conditionalFormatting close without open") + _ => () + } + let rel_start = match rel_start { Some(pos) => pos None => break } let start = search_from + rel_start let after_needle = start + needle.length() - let next_char = xml_str[after_needle:after_needle + 1] - if next_char == "s" { + if after_needle >= xml.length() { + raise InvalidXml(msg="x14:conditionalFormatting tag not closed") + } + if xml[after_needle] == ('s' : UInt16) { search_from = after_needle continue } - let after_start = xml_str[start:] - let after_text = after_start.to_owned() - let (full, consumed) = match after_text.find(close_tag) { - Some(pos) => { - let end = pos + close_tag.length() - let slice = after_text[:end] - (slice.to_owned(), end) - } - None => { - let end = match after_text.find("/>") { - Some(pos) => pos + 2 - None => - raise InvalidXml(msg="x14:conditionalFormatting tag not closed") - } - let slice = after_text[:end] - (slice.to_owned(), end) + if !is_xml_open_tag_boundary(xml[after_needle]) { + search_from = after_needle + continue + } + let open_end = match xml_open_tag_end_from(xml, after_needle) { + Some(pos) => pos + None => raise InvalidXml(msg="x14:conditionalFormatting tag not closed") + } + let self_closing = open_end > start && xml[open_end - 1] == ('/' : UInt16) + let end = if self_closing { + open_end + 1 + } else { + let body_start = open_end + 1 + let relative_close = match xml[body_start:].find(close_tag) { + Some(pos) => pos + None => raise InvalidXml(msg="x14:conditionalFormatting tag not closed") } + body_start + relative_close + close_tag.length() } - blocks.push(full) - search_from = start + consumed + blocks.push(xml[start:end].to_owned()) + search_from = end } blocks } @@ -2916,6 +2895,10 @@ fn parse_inline_string(rest : StringView) -> InlineStringEntry raise XlsxError { fn parse_row_dimensions( xml : StringView, style_count : Int, + materialized_dimensions : Ref[Int], + max_materialized_dimensions : Int, + dimension_work : Ref[Int], + max_dimension_work : Int, ) -> Map[Int, RowDimension] raise XlsxError { let dims : Map[Int, RowDimension] = Map([]) let mut first = true @@ -2982,6 +2965,25 @@ fn parse_row_dimensions( if height is None && !hidden && outline_level == 0 && style is None { continue } + if dimension_work.val >= max_dimension_work { + raise ResourceLimitExceeded( + kind="row_column_dimension_work", + limit=max_dimension_work, + actual=bounded_actual_above_limit(max_dimension_work), + ) + } + dimension_work.val = dimension_work.val + 1 + if !dims.contains(row) { + if materialized_dimensions.val >= max_materialized_dimensions { + raise ResourceLimitExceeded( + kind="materialized_row_column_dimensions", + limit=max_materialized_dimensions, + actual=bounded_actual_above_limit(max_materialized_dimensions), + ) + } + let next = materialized_dimensions.val + 1 + materialized_dimensions.val = next + } dims[row] = { height, hidden, outline_level, style_id: style } } dims @@ -2991,6 +2993,10 @@ fn parse_row_dimensions( fn parse_col_dimensions( xml : StringView, style_count : Int, + materialized_dimensions : Ref[Int], + max_materialized_dimensions : Int, + dimension_work : Ref[Int], + max_dimension_work : Int, ) -> Map[Int, ColDimension] raise XlsxError { let dims : Map[Int, ColDimension] = Map([]) let body = match extract_tag_body(xml, "cols") { @@ -3070,9 +3076,29 @@ fn parse_col_dimensions( if width is None && !hidden && outline_level == 0 && style is None { continue } + let span = max_col - min_col + 1 + if span > max_dimension_work - dimension_work.val { + raise ResourceLimitExceeded( + kind="row_column_dimension_work", + limit=max_dimension_work, + actual=bounded_actual_above_limit(max_dimension_work), + ) + } + dimension_work.val = dimension_work.val + span let dim = { width, hidden, outline_level, style_id: style } let mut col = min_col while col <= max_col { + if !dims.contains(col) { + if materialized_dimensions.val >= max_materialized_dimensions { + raise ResourceLimitExceeded( + kind="materialized_row_column_dimensions", + limit=max_materialized_dimensions, + actual=bounded_actual_above_limit(max_materialized_dimensions), + ) + } + let next = materialized_dimensions.val + 1 + materialized_dimensions.val = next + } dims[col] = dim col = col + 1 } @@ -3624,7 +3650,8 @@ fn read_zip_archive_core_unchecked( transcoder? : (String, Bytes) -> String raise XlsxError, ) -> Workbook raise XlsxError { let read_budget = ReadBudget::new(limits, cancelled~) - let part_names = archive_part_name_index(archive, budget=read_budget) + let decoded_xml_bytes = Ref(0) + let xml_markup_tokens = Ref(0) let decode_source = (value : BytesView) => { if value.length() > limits.max_xml_part_bytes { raise ResourceLimitExceeded( @@ -3633,10 +3660,44 @@ fn read_zip_archive_core_unchecked( actual=value.length(), ) } + if value.length() > limits.max_total_xml_bytes - decoded_xml_bytes.val { + raise ResourceLimitExceeded( + kind="total_xml_bytes", + limit=limits.max_total_xml_bytes, + actual=bounded_actual_above_limit(limits.max_total_xml_bytes), + ) + } + let next_xml_bytes = decoded_xml_bytes.val + value.length() + decoded_xml_bytes.val = next_xml_bytes + for byte in value { + if byte == b'<' { + if xml_markup_tokens.val >= limits.max_xml_markup_tokens { + raise ResourceLimitExceeded( + kind="xml_markup_tokens", + limit=limits.max_xml_markup_tokens, + actual=bounded_actual_above_limit(limits.max_xml_markup_tokens), + ) + } + let next_token_count = xml_markup_tokens.val + 1 + xml_markup_tokens.val = next_token_count + } + } let decoded = decode_utf8(value, transcoder) read_budget.scan_xml(decoded) decoded } + preflight_archive_relationship_limits( + archive, + limits, + decode=decode_source, + budget=read_budget, + cancelled~, + ) + let part_names = archive_part_name_index(archive, budget=read_budget) + let materialized_cells = Ref(0) + let materialized_dimensions = Ref(0) + let dimension_work = Ref(0) + let drawing_media_cache : Map[String, Bytes] = Map([]) let decode = (value : BytesView) => { let decoded = decode_source(value) read_budget.charge_work(decoded.length()) @@ -4200,6 +4261,12 @@ fn read_zip_archive_core_unchecked( sheet_core_xml, shared_strings, style_count, + materialized_cells, + limits.max_materialized_cells, + materialized_dimensions, + limits.max_materialized_row_column_dimensions, + dimension_work, + limits.max_row_column_dimension_work, budget=read_budget, ) let hyperlink_elements = parse_hyperlink_elements(sheet_core_xml) @@ -4889,6 +4956,7 @@ fn read_zip_archive_core_unchecked( content_types, drawing_metrics, archive, + media_cache=drawing_media_cache, budget=read_budget, cancelled~, ) diff --git a/xlsx/read_drawing_xml.mbt b/xlsx/read_drawing_xml.mbt index fe793b68..fe2637d3 100644 --- a/xlsx/read_drawing_xml.mbt +++ b/xlsx/read_drawing_xml.mbt @@ -641,10 +641,12 @@ fn parse_drawing_images( content_types : @ooxml.PackageContentTypes, metrics : DrawingMetrics, archive : @zip.Archive, + media_cache? : Map[String, Bytes], budget? : ReadBudget, cancelled? : () -> Bool = () => false, ) -> Array[Image] raise XlsxError { let images : Array[Image] = [] + let retained_media = media_cache.unwrap_or(Map([])) let image_targets = if drawing_rels_xml.to_owned() == "" { Map([]) } else { @@ -744,13 +746,21 @@ fn parse_drawing_images( part_names, cancelled~, ) - let (data, identity) = load_image_part( + let (source_data, identity) = load_image_part( archive, content_types, media_path, "drawing image", cancelled~, ) + let data = match retained_media.get(media_path) { + Some(value) => value + None => { + let value = source_data.to_owned() + retained_media[media_path] = value + value + } + } let mut hyperlink = "" let mut hyperlink_type = HyperlinkType::Unset match tag_attributes_in(pic_body, "a:hlinkClick") { @@ -777,7 +787,7 @@ fn parse_drawing_images( } images.push({ reference, - data: data.to_owned(), + data, extension: identity.extension, content_type: identity.content_type, offset_x, @@ -1813,6 +1823,29 @@ test "read_drawing_xml wb: signed marker offsets are retained" { inspect(images[0].offset_y, content="-1") } +///| +test "read_drawing_xml wb: repeated anchors share one retained media payload" { + let archive = @zip.Archive::new() + archive.add("xl/media/image1.png", @encoding/utf8.encode("shared-image")) + let rels_xml = wb_rels_image_target("../media/image1.png") + let anchor = "\{wb_from_xml()}\{wb_pic_xml("rId1")}" + let drawing_xml = "\{anchor}\{anchor}" + let retained : Map[String, Bytes] = Map([]) + let images = parse_drawing_images( + parse_drawing_anchors(drawing_xml), + rels_xml, + "xl/drawings/drawing1.xml", + Map([]), + image_reader_test_content_types(), + drawing_metrics_for_sheet(Worksheet::new("Sheet1")), + archive, + media_cache=retained, + ) + assert_eq(images.length(), 2) + assert_eq(retained.length(), 1) + assert_eq(images[0].data, images[1].data) +} + ///| test "read_drawing_xml wb: parse_drawing_images anchor/ext validation errors" { let archive = @zip.Archive::new() diff --git a/xlsx/read_limits_test.mbt b/xlsx/read_limits_test.mbt index 892d8a2d..98a1d5f1 100644 --- a/xlsx/read_limits_test.mbt +++ b/xlsx/read_limits_test.mbt @@ -38,6 +38,16 @@ fn replace_xlsx_part( @zip.write(archive) } +///| +fn add_unused_relationship_part( + source : BytesView, + content : String, +) -> Bytes raise { + let archive = @zip.read(source) + archive.add("custom/_rels/unused.rels", @encoding/utf8.encode(content)) + @zip.write(archive) +} + ///| fn lying_deflate_package() -> Bytes raise { let archive = @zip.Archive::new() @@ -109,6 +119,97 @@ test "direct XLSX reads reject OPC-derivable part names" { } } +///| +test "bounded reads cap materialized cells across worksheets" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("One")) + ignore(workbook.add_sheet("Two")) + workbook.set_cell("One", "A1", "first") + workbook.set_cell("Two", "A1", "second") + let bytes = @xlsx.write(workbook) + let result : Result[@xlsx.Workbook, Error] = Ok( + @xlsx.read( + bytes, + limits=@xlsx.ReadLimits::with_values(max_materialized_cells=1), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="materialized_cells") + assert_eq(limit, 1) + assert_eq(actual, 2) + } + _ => fail("expected aggregate materialized-cell limit") + } +} + +///| +test "bounded reads cap retained row and column dimensions across worksheets" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("One")) + ignore(workbook.add_sheet("Two")) + workbook.set_row_height("One", 1, 20.0) + workbook.set_col_width("Two", 1, 12.0) + let result : Result[@xlsx.Workbook, Error] = Ok( + @xlsx.read( + @xlsx.write(workbook), + limits=@xlsx.ReadLimits::with_values( + max_materialized_row_column_dimensions=1, + ), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="materialized_row_column_dimensions") + assert_eq(limit, 1) + assert_eq(actual, 2) + } + _ => fail("expected aggregate row/column-dimension limit") + } +} + +///| +test "bounded reads cap overlapping row and column dimension expansion work" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Data")) + let archive = @zip.read(@xlsx.write(workbook)) + let path = "xl/worksheets/sheet1.xml" + let sheet_xml = match archive.get(path) { + Some(value) => @encoding/utf8.decode(value) + None => fail("missing worksheet XML") + } + let dimensions = + #| + assert_true( + archive.replace( + path, + @encoding/utf8.encode( + sheet_xml.replace(old="", new=dimensions), + ), + ), + ) + let result : Result[@xlsx.Workbook, Error] = Ok( + @xlsx.read( + @zip.write(archive), + limits=@xlsx.ReadLimits::with_values(max_row_column_dimension_work=5), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="row_column_dimension_work") + assert_eq(limit, 5) + assert_true(actual > limit) + } + _ => fail("expected row/column dimension work limit") + } +} + ///| test "archive read rejects compatibility and constructed archives" { let bytes = bounded_workbook_fixture() @@ -429,6 +530,60 @@ test "semantic read limits bound cumulative parser work" { } } +///| +test "semantic read preflight charges unused relationship XML" { + let source = bounded_workbook_fixture() + let large_relationships = add_unused_relationship_part( + source, + "" + + " ".repeat(2 * 1024 * 1024) + + "", + ) + let markup_relationships = add_unused_relationship_part( + source, + "" + + "".repeat(8192) + + "", + ) + let aggregate_limit = 1024 * 1024 + let markup_limit = 4096 + let aggregate_limits = @xlsx.ReadLimits::with_values( + max_total_xml_bytes=aggregate_limit, + ) + let work_limits = @xlsx.ReadLimits::with_values( + max_parser_work_units=aggregate_limit, + ) + let markup_limits = @xlsx.ReadLimits::with_values( + max_xml_markup_tokens=markup_limit, + ) + // Each policy accepts the ordinary workbook; only the unused relationship + // part pushes the corresponding shared budget over its ceiling. + for limits in [aggregate_limits, work_limits, markup_limits] { + ignore(@xlsx.read(source, limits~)) + } + for + entry in [ + ( + large_relationships, aggregate_limits, "total_xml_bytes", aggregate_limit, + ), + (large_relationships, work_limits, "parser_work_units", aggregate_limit), + (markup_relationships, markup_limits, "xml_markup_tokens", markup_limit), + ] { + let (bytes, limits, expected_kind, expected_limit) = entry + let result : Result[@xlsx.Workbook, Error] = Ok(@xlsx.read(bytes, limits~)) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + assert_eq(kind, expected_kind) + assert_eq(limit, expected_limit) + assert_true(actual > limit) + } + _ => fail("expected unused relationship XML limit") + } + } +} + ///| test "semantic read limits bound sheet fan-out before worksheet parsing" { let workbook = @xlsx.Workbook::new() diff --git a/xlsx/read_resource_wbtest.mbt b/xlsx/read_resource_wbtest.mbt new file mode 100644 index 00000000..37b5d4ae --- /dev/null +++ b/xlsx/read_resource_wbtest.mbt @@ -0,0 +1,32 @@ +///| +test "read resource wb: x14 block scanning retains only matched blocks" { + let xml = StringBuilder::new() + xml.write_view("") + for index in 0..<2_000 { + xml.write_view( + "", + ) + } + xml.write_view("") + let blocks = parse_x14_conditional_formatting_blocks(xml.to_string()) + assert_eq(blocks.length(), 2_000) + assert_true(blocks[0].contains("id=\"0\"")) + assert_true(blocks[1_999].contains("id=\"1999\"")) +} + +///| +test "read resource wb: x14 scanning rejects EOF and separates self-closing blocks" { + let eof : Result[Array[String], Error] = Ok( + parse_x14_conditional_formatting_blocks(" Err(error) + } + assert_true(eof is Err(InvalidXml(_))) + + let blocks = parse_x14_conditional_formatting_blocks( + "A1", + ) + assert_eq(blocks.length(), 2) + inspect(blocks[0], content="") + assert_true(blocks[1].contains("A1")) +} diff --git a/xlsx/read_worksheet_xml.mbt b/xlsx/read_worksheet_xml.mbt index df671f0e..184d240d 100644 --- a/xlsx/read_worksheet_xml.mbt +++ b/xlsx/read_worksheet_xml.mbt @@ -28,6 +28,12 @@ fn parse_worksheet( xml : StringView, shared_strings : Array[SharedStringEntry], style_count : Int, + materialized_cells : Ref[Int], + max_materialized_cells : Int, + materialized_dimensions : Ref[Int], + max_materialized_dimensions : Int, + dimension_work : Ref[Int], + max_dimension_work : Int, budget? : ReadBudget, ) -> ( Array[Cell], @@ -64,6 +70,15 @@ fn parse_worksheet( if chunk.length() == 0 || !is_xml_open_tag_boundary(chunk[0]) { continue } + if materialized_cells.val >= max_materialized_cells { + raise ResourceLimitExceeded( + kind="materialized_cells", + limit=max_materialized_cells, + actual=bounded_actual_above_limit(max_materialized_cells), + ) + } + let next_cell_count = materialized_cells.val + 1 + materialized_cells.val = next_cell_count let text = chunk.to_owned() let end = match xml_open_tag_end_from(text, 0) { Some(pos) => pos @@ -295,9 +310,15 @@ fn parse_worksheet( worksheet_parse_pass(xml, budget) let auto_filter = parse_auto_filter(xml) worksheet_parse_pass(sheet_data, budget) - let row_dimensions = parse_row_dimensions(sheet_data, style_count) + let row_dimensions = parse_row_dimensions( + sheet_data, style_count, materialized_dimensions, max_materialized_dimensions, + dimension_work, max_dimension_work, + ) worksheet_parse_pass(xml, budget) - let col_dimensions = parse_col_dimensions(xml, style_count) + let col_dimensions = parse_col_dimensions( + xml, style_count, materialized_dimensions, max_materialized_dimensions, dimension_work, + max_dimension_work, + ) worksheet_parse_pass(xml, budget) let page_margins = parse_page_margins(xml) worksheet_parse_pass(xml, budget) diff --git a/xlsx/relationship_limits.mbt b/xlsx/relationship_limits.mbt new file mode 100644 index 00000000..97b646f3 --- /dev/null +++ b/xlsx/relationship_limits.mbt @@ -0,0 +1,180 @@ +///| +let package_relationships_namespace_for_validation = "http://schemas.openxmlformats.org/package/2006/relationships" + +///| +fn is_relationship_package_part(name : StringView) -> Bool { + name.to_owned().to_lower().has_suffix(".rels") +} + +///| +fn relationship_scanner_attribute_view( + scanner : @ooxml.XmlStartTagScanner, + name : StringView, +) -> StringView? raise XlsxError { + scanner.attribute_view("", name) catch { + InvalidXml(msg~) => raise InvalidXml(msg~) + ReadCancelled => raise ReadCancelled + } +} + +///| +fn relationship_scanner_attribute( + scanner : @ooxml.XmlStartTagScanner, + name : StringView, +) -> String? raise XlsxError { + scanner.attribute("", name) catch { + InvalidXml(msg~) => raise InvalidXml(msg~) + ReadCancelled => raise ReadCancelled + } +} + +///| +/// Visits relationship start tags through the namespace-aware XML scanner. +/// In particular, XML whitespace after the QName and declared QName prefixes +/// are accepted instead of relying on one serialized spelling such as +/// ` Bool raise XlsxError, + cancelled? : () -> Bool = () => false, +) -> Unit raise XlsxError { + let scanner = @ooxml.XmlStartTagScanner::new(xml, cancelled~) + let mut scanning = true + while scanning { + let advanced = scanner.next() catch { + InvalidXml(msg~) => raise InvalidXml(msg~) + ReadCancelled => raise ReadCancelled + } + if !advanced { + scanning = false + continue + } + if scanner.namespace_uri() == package_relationships_namespace_for_validation && + scanner.local_name() == "Relationship" { + if !visit(scanner) { + scanning = false + } + } + } +} + +///| +fn charge_relationship_attribute( + value : StringView, + kind : String, + maximum : Int, + total : Ref[Int], + total_maximum : Int, +) -> Unit raise XlsxError { + if value.length() > maximum { + raise ResourceLimitExceeded(kind~, limit=maximum, actual=value.length()) + } + if value.length() > total_maximum - total.val { + raise ResourceLimitExceeded( + kind="total_relationship_chars", + limit=total_maximum, + actual=bounded_actual_above_limit(total_maximum), + ) + } + total.val = total.val + value.length() +} + +///| +/// Preflights every relationship part before any relationship map or resolved +/// path is materialized. Attribute values stay borrowed until their individual +/// and package-wide ceilings have been charged. This makes the limits +/// cumulative even though downstream readers intentionally build separate maps +/// for different relationship types. +fn preflight_archive_relationship_limits( + archive : @zip.Archive, + limits : ReadLimits, + transcoder? : (String, Bytes) -> String raise XlsxError, + decode? : (BytesView) -> String raise XlsxError, + budget? : ReadBudget, + cancelled? : () -> Bool = () => false, +) -> Unit raise XlsxError { + let records = Ref(0) + let attribute_chars = Ref(0) + for entry in archive.entries() { + check_read_cancelled(cancelled) + if !is_relationship_package_part(entry.name()) { + continue + } + let xml = match decode { + Some(value) => value(entry.data()) + None => decode_utf8(entry.data(), transcoder) + } + match budget { + Some(value) => { + value.checkpoint() + // The namespace-aware visitor below is an additional full pass beyond + // the source preflight performed by the shared decoder. + value.charge_work(xml.length()) + } + None => () + } + for_each_relationship_start_tag( + xml, + scanner => { + check_read_cancelled(cancelled) + records.val = records.val + 1 + if records.val > limits.max_relationship_records { + raise ResourceLimitExceeded( + kind="relationship_records", + limit=limits.max_relationship_records, + actual=records.val, + ) + } + match relationship_scanner_attribute_view(scanner, "Id") { + Some(value) => + charge_relationship_attribute( + value, + "relationship_id_chars", + limits.max_relationship_id_chars, + attribute_chars, + limits.max_total_relationship_chars, + ) + None => () + } + match relationship_scanner_attribute_view(scanner, "Type") { + Some(value) => + charge_relationship_attribute( + value, + "relationship_type_chars", + limits.max_relationship_type_chars, + attribute_chars, + limits.max_total_relationship_chars, + ) + None => () + } + match relationship_scanner_attribute_view(scanner, "Target") { + Some(value) => + charge_relationship_attribute( + value, + "relationship_target_chars", + limits.max_relationship_target_chars, + attribute_chars, + limits.max_total_relationship_chars, + ) + None => () + } + // TargetMode is normally one of two short tokens. Charge it under the + // type ceiling so an irrelevant attacker-sized value cannot hide outside + // the package-wide relationship budget. + match relationship_scanner_attribute_view(scanner, "TargetMode") { + Some(value) => + charge_relationship_attribute( + value, + "relationship_mode_chars", + limits.max_relationship_type_chars, + attribute_chars, + limits.max_total_relationship_chars, + ) + None => () + } + true + }, + cancelled~, + ) + } +} diff --git a/xlsx/slicer.mbt b/xlsx/slicer.mbt index 0afe8227..c6b5d872 100644 --- a/xlsx/slicer.mbt +++ b/xlsx/slicer.mbt @@ -85,21 +85,24 @@ let slicer_ns_xr10 = "http://schemas.microsoft.com/office/spreadsheetml/2016/rev let slicer_ext_uri_pivot_cache_definition = "{725AE2AE-9491-48be-B2B4-4EB974FC3084}" ///| -fn write_slicers_part_xml(slicers : ArrayView[Slicer]) -> String { - let sb = StringBuilder::new() +fn write_slicers_part_xml( + slicers : ArrayView[Slicer], + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", ) for slicer in slicers { sb.write_view(" String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") @@ -162,15 +166,16 @@ fn write_slicer_cache_xml_table( table_id : Int, column : Int, item_desc : Bool, -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n") sb.write_view(" \n") sb.write_view(" \n") @@ -204,49 +209,64 @@ fn parse_pivot_cache_slicer_id(cache_xml : StringView) -> Int? raise XlsxError { ///| fn ensure_pivot_cache_slicer_id( - cache_xml : StringView, + cache_xml : String, pivot_cache_id : Int, + budget? : WritePartBudget, ) -> String raise XlsxError { - let mut xml = cache_xml.to_owned() - if parse_pivot_cache_slicer_id(xml) is Some(_) { - return xml + if parse_pivot_cache_slicer_id(cache_xml) is Some(_) { + return cache_xml } - if !xml.contains("xmlns:x14=\"\{slicer_ns_x14}\"") { + let namespace_at = if !cache_xml.contains("xmlns:x14=\"\{slicer_ns_x14}\"") { let needle = " { - let rest = xml[start:] + let rest = cache_xml[start:] match rest.find(">") { - Some(end_rel) => { - let end = start + end_rel - let before = xml[:end] - let after = xml[end:] - xml = "\{before} xmlns:x14=\"\{slicer_ns_x14}\"\{after}" - } - None => () + Some(end_rel) => Some(start + end_rel) + None => None } } - None => () + None => None } + } else { + None } - if xml.contains("uri=\"\{slicer_ext_uri_pivot_cache_definition}\"") { - return xml + let extension_insertion : (Int, String)? = if cache_xml.contains( + "uri=\"\{slicer_ext_uri_pivot_cache_definition}\"", + ) { + None + } else { + let ext_entry = " \n \n \n" + match cache_xml.find("") { + Some(pos) => Some((pos, ext_entry)) + None => + match cache_xml.find("") { + Some(pos) => Some((pos, " \n\{ext_entry} \n")) + None => None + } + } + } + if namespace_at is None && extension_insertion is None { + return cache_xml } - let ext_entry = " \n \n \n" - match xml.find("") { + let sb = LimitedXmlBuilder::new(budget) + let mut cursor = 0 + match namespace_at { Some(pos) => { - let head = xml[:pos] - let tail = xml[pos:] - return "\{head}\{ext_entry}\{tail}" + sb.write_view(cache_xml[cursor:pos]) + sb.write_view(" xmlns:x14=\"\{slicer_ns_x14}\"") + cursor = pos } None => () } - match xml.find("") { - Some(pos) => { - let head = xml[:pos] - let tail = xml[pos:] - "\{head} \n\{ext_entry} \n\{tail}" + match extension_insertion { + Some((pos, fragment)) => { + sb.write_view(cache_xml[cursor:pos]) + sb.write_view(fragment) + cursor = pos } - None => xml + None => () } + sb.write_view(cache_xml[cursor:]) + sb.to_string() } diff --git a/xlsx/sparkline.mbt b/xlsx/sparkline.mbt index 4aa4f187..0e89171c 100644 --- a/xlsx/sparkline.mbt +++ b/xlsx/sparkline.mbt @@ -748,10 +748,10 @@ fn sparkline_preset(style : Int) -> SparklinePreset raise XlsxError { ///| fn write_sparkline_color_xml( - sb : StringBuilder, + sb : LimitedXmlBuilder, tag_name : StringView, color : SparklineColor?, -) -> Unit { +) -> Unit raise XlsxError { match color { None => () Some(value) => { @@ -760,7 +760,7 @@ fn write_sparkline_color_xml( match value.rgb { Some(rgb) => { sb.write_view(" rgb=\"") - sb.write_view(escape_xml_attr(rgb)) + sb.write_xml_attr(rgb) sb.write_view("\"") } None => () @@ -779,19 +779,26 @@ fn write_sparkline_color_xml( } ///| -fn quote_sheet_name_for_formula(sheet_name : StringView) -> String { - "'" + sheet_name.to_owned().replace_all(old="'", new="''") + "'" -} - -///| -fn sparkline_formula_ref_for_xml( +fn write_sparkline_formula_ref_xml( + sb : LimitedXmlBuilder, sheet_name : StringView, range_ref : StringView, -) -> String { +) -> Unit raise XlsxError { if range_ref.contains("!") { - range_ref.to_owned() + sb.write_xml_text(range_ref) } else { - "\{quote_sheet_name_for_formula(sheet_name)}!\{range_ref.to_owned()}" + sb.write_char('\'') + for character in sheet_name { + match character { + '\'' => sb.write_view("''") + '&' => sb.write_view("&") + '<' => sb.write_view("<") + '>' => sb.write_view(">") + _ => sb.write_char(character) + } + } + sb.write_view("'!") + sb.write_xml_text(range_ref) } } @@ -799,10 +806,17 @@ fn sparkline_formula_ref_for_xml( fn write_sparkline_ext_xml( sheet_name : StringView, groups : ArrayView[SparklineGroup], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") - sb.write_view(write_sparkline_ext_block_xml(sheet_name, groups)) + sb.write_view( + write_sparkline_ext_block_xml( + sheet_name, + groups, + budget?=sb.remaining_budget(), + ), + ) sb.write_view(" \n") sb.to_string() } @@ -811,8 +825,9 @@ fn write_sparkline_ext_xml( fn write_sparkline_ext_block_xml( sheet_name : StringView, groups : ArrayView[SparklineGroup], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") @@ -827,7 +842,7 @@ fn write_sparkline_ext_block_xml( sb.write_view("\"") if group.options.display_empty_cells_as != "" { sb.write_view(" displayEmptyCellsAs=\"") - sb.write_view(escape_xml_attr(group.options.display_empty_cells_as)) + sb.write_xml_attr(group.options.display_empty_cells_as) sb.write_view("\"") } if group.options.date_axis { @@ -885,14 +900,10 @@ fn write_sparkline_ext_block_xml( for sparkline in group.sparklines { sb.write_view(" \n") sb.write_view(" ") - sb.write_view( - escape_xml_text( - sparkline_formula_ref_for_xml(sheet_name, sparkline.range_ref), - ), - ) + write_sparkline_formula_ref_xml(sb, sheet_name, sparkline.range_ref) sb.write_view("\n") sb.write_view(" ") - sb.write_view(escape_xml_text(sparkline.location)) + sb.write_xml_text(sparkline.location) sb.write_view("\n") sb.write_view(" \n") } diff --git a/xlsx/style.mbt b/xlsx/style.mbt index d9622fcc..71e5df24 100644 --- a/xlsx/style.mbt +++ b/xlsx/style.mbt @@ -1057,7 +1057,8 @@ fn write_styles_xml( indexed_colors : Array[String]?, mru_colors_xml : String?, styles_ext_lst_xml : String?, -) -> String { + budget? : WritePartBudget, +) -> String raise XlsxError { let style_list : Array[Style] = [] for style in styles { style_list.push(style) @@ -1100,7 +1101,7 @@ fn write_styles_xml( _ => () } } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -1110,7 +1111,7 @@ fn write_styles_xml( for entry in num_fmt_entries { let (id, code) = entry sb.write_view(" \n") } sb.write_view(" \n") @@ -1199,7 +1200,10 @@ fn write_styles_xml( "\{family}|\{size}|\{bold}|\{italic}|\{strike}|\{outline}|\{shadow}|\{condense}|\{extended}|\{underline}|\{color}|\{color_theme}|\{color_indexed}|\{color_tint}|\{charset}|\{family_number}|\{scheme}|\{vert_align}" } - fn write_font_color(sb : StringBuilder, font : Font) -> Unit { + fn write_font_color( + sb : LimitedXmlBuilder, + font : Font, + ) -> Unit raise XlsxError { let tint = match font.color_tint { Some(v) => if v == 0.0 { None } else { Some(v) } None => None @@ -1236,7 +1240,9 @@ fn write_styles_xml( } else { normalized } - sb.write_view(" sb.write_view(" tint=\"\{v}\"") None => () @@ -1293,8 +1299,11 @@ fn write_styles_xml( _ => () } match font.underline { - Some(value) => - sb.write_view(" \n") + Some(value) => { + sb.write_view(" \n") + } None => () } match font.size { @@ -1311,7 +1320,7 @@ fn write_styles_xml( None => font_name } sb.write_view(" \n") let family_number = match font.family_number { Some(value) => value @@ -1319,13 +1328,19 @@ fn write_styles_xml( } sb.write_view(" \n") match font.vert_align { - Some(value) => - sb.write_view(" \n") + Some(value) => { + sb.write_view(" \n") + } None => () } match font.scheme { - Some(value) => - sb.write_view(" \n") + Some(value) => { + sb.write_view(" \n") + } None => () } sb.write_view(" \n") @@ -1341,6 +1356,10 @@ fn write_styles_xml( let fills : Array[String] = [ " \n", " \n", ] + let fill_budget = WritePartBudgetTracker::new(sb.remaining_budget()) + for entry in fills { + fill_budget.consume(entry) + } let fill_ids : Map[String, Int] = Map([]) fn normalize_pattern_fill_key( pattern : Int, @@ -1394,10 +1413,11 @@ fn write_styles_xml( tint : Double?, rgb : String?, transparency : Int, - ) -> String { + budget : WritePartBudget?, + ) -> String raise XlsxError { match theme { Some(value) => { - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("theme=\"\{value}\"") match tint { Some(v) => if v != 0.0 { sb.write_view(" tint=\"\{v}\"") } @@ -1409,7 +1429,7 @@ fn write_styles_xml( } match indexed { Some(value) => { - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("indexed=\"\{value}\"") match tint { Some(v) => if v != 0.0 { sb.write_view(" tint=\"\{v}\"") } @@ -1420,7 +1440,13 @@ fn write_styles_xml( None => () } match rgb { - Some(value) => "rgb=\"\{escape_xml_attr(fill_rgb(value, transparency))}\"" + Some(value) => { + let sb = LimitedXmlBuilder::new(budget) + sb.write_view("rgb=\"") + sb.write_xml_attr(fill_rgb(value, transparency)) + sb.write_view("\"") + sb.to_string() + } None => "" } } @@ -1429,14 +1455,18 @@ fn write_styles_xml( pattern : Int, fg_attrs : String, bg_attrs : String, - ) -> String { + budget : WritePartBudget?, + ) -> String raise XlsxError { let pattern_type = fill_patterns[pattern] + let sb = LimitedXmlBuilder::new(budget) if fg_attrs == "" && bg_attrs == "" { - return " \n" + sb.write_view(" \n") + return sb.to_string() } - let sb = StringBuilder::new() sb.write_view(" ") if fg_attrs != "" { sb.write_view("") @@ -1454,8 +1484,9 @@ fn write_styles_xml( c2 : String, indent : StringView, newline : Bool, - ) -> String { - let sb = StringBuilder::new() + budget : WritePartBudget?, + ) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(indent) sb.write_view("") for i, pos in variant.stops { let color = if i == 1 { c2 } else { c1 } sb.write_view("") } sb.write_view("") @@ -1519,6 +1552,7 @@ fn write_styles_xml( fill.fg_tint, fg_rgb, transparency, + fill_budget.remaining_budget(), ) let bg_attrs = fill_color_attrs( fill.bg_theme, @@ -1526,13 +1560,21 @@ fn write_styles_xml( fill.bg_tint, bg_rgb, transparency, + fill_budget.remaining_budget(), ) let key = normalize_pattern_fill_key( pattern, fg_attrs, bg_attrs, ) if !fill_ids.contains(key) { fill_ids[key] = fills.length() - fills.push(pattern_fill_xml(pattern, fg_attrs, bg_attrs)) + let entry = pattern_fill_xml( + pattern, + fg_attrs, + bg_attrs, + fill_budget.remaining_budget(), + ) + fill_budget.consume(entry) + fills.push(entry) } } } else if t == "gradient" { @@ -1554,15 +1596,16 @@ fn write_styles_xml( let key = normalize_gradient_fill_key(shading, c1, c2) if !fill_ids.contains(key) { fill_ids[key] = fills.length() - fills.push( - gradient_fill_xml( - fill_gradient_variants[shading], - c1, - c2, - " ", - true, - ), + let entry = gradient_fill_xml( + fill_gradient_variants[shading], + c1, + c2, + " ", + true, + fill_budget.remaining_budget(), ) + fill_budget.consume(entry) + fills.push(entry) } } _ => () @@ -1711,7 +1754,7 @@ fn write_styles_xml( } let style_name = border_styles[style] sb.write_view("<\{side} style=\"") - sb.write_view(escape_xml_attr(style_name)) + sb.write_xml_attr(style_name) sb.write_view("\">") match color { Some(value) => { @@ -1722,7 +1765,7 @@ fn write_styles_xml( normalized } sb.write_view("") } None => () @@ -1784,6 +1827,7 @@ fn write_styles_xml( fill.fg_tint, fg_rgb, transparency, + sb.remaining_budget(), ) let bg_attrs = fill_color_attrs( fill.bg_theme, @@ -1791,6 +1835,7 @@ fn write_styles_xml( fill.bg_tint, bg_rgb, transparency, + sb.remaining_budget(), ) match fill_ids.get( @@ -1908,11 +1953,19 @@ fn write_styles_xml( if apply_alignment { sb.write_view(" sb.write_view(" horizontal=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" horizontal=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match a.vertical { - Some(v) => sb.write_view(" vertical=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" vertical=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match a.wrap_text { @@ -2067,7 +2120,7 @@ fn write_styles_xml( match num_fmt_ids.get(code) { Some(id) => { sb.write_view("") } None => () @@ -2107,8 +2160,11 @@ fn write_styles_xml( _ => () } match font.underline { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } match font.size { @@ -2147,7 +2203,9 @@ fn write_styles_xml( } else { normalized } - sb.write_view(" sb.write_view(" tint=\"\{v}\"") None => () @@ -2165,7 +2223,7 @@ fn write_styles_xml( match font.family { Some(value) => { sb.write_view("") } None => () @@ -2176,13 +2234,19 @@ fn write_styles_xml( } sb.write_view("") match font.vert_align { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } match font.scheme { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } sb.write_view("") @@ -2226,10 +2290,15 @@ fn write_styles_xml( None => fill.fg_tint } let bg_attrs = fill_color_attrs( - bg_theme, bg_indexed, bg_tint, bg_rgb, transparency, + bg_theme, + bg_indexed, + bg_tint, + bg_rgb, + transparency, + sb.remaining_budget(), ) sb.write_view("") if bg_attrs != "" { sb.write_view("") @@ -2259,6 +2328,7 @@ fn write_styles_xml( c2, "", false, + sb.remaining_budget(), ), ) } @@ -2298,7 +2368,7 @@ fn write_styles_xml( } let style_name = border_styles[style] sb.write_view("<\{side} style=\"") - sb.write_view(escape_xml_attr(style_name)) + sb.write_xml_attr(style_name) sb.write_view("\">") match color { Some(value) => { @@ -2309,7 +2379,7 @@ fn write_styles_xml( normalized } sb.write_view("") } None => () @@ -2325,11 +2395,19 @@ fn write_styles_xml( if has_alignment { sb.write_view(" sb.write_view(" horizontal=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" horizontal=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match a.vertical { - Some(v) => sb.write_view(" vertical=\"\{escape_xml_attr(v)}\"") + Some(v) => { + sb.write_view(" vertical=\"") + sb.write_xml_attr(v) + sb.write_view("\"") + } None => () } match a.wrap_text { @@ -2405,9 +2483,9 @@ fn write_styles_xml( sb.write_view(" \n") } sb.write_view(" \n") let has_indexed = match indexed_colors { Some(colors) => colors.length() > 0 @@ -2428,7 +2506,7 @@ fn write_styles_xml( let upper = normalized.to_upper() let rgb = if upper.length() == 6 { "FF" + upper } else { upper } sb.write_view("") } sb.write_view("") diff --git a/xlsx/table.mbt b/xlsx/table.mbt index eaee1ed7..dc5f4a62 100644 --- a/xlsx/table.mbt +++ b/xlsx/table.mbt @@ -159,7 +159,11 @@ fn Table::new( } ///| -fn write_table_xml(table_id : Int, table : Table) -> String { +fn write_table_xml( + table_id : Int, + table : Table, + budget? : WritePartBudget, +) -> String raise XlsxError { fn bool_attr(value : Bool) -> String { if value { "1" @@ -168,34 +172,34 @@ fn write_table_xml(table_id : Int, table : Table) -> String { } } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n") } else { sb.write_view("\" totalsRowShown=\"0\">\n") sb.write_view(" \n") } sb.write_view(" \n") for i, name in table.columns { let id = i + 1 sb.write_view(" \n") } sb.write_view(" \n") sb.write_view(" \n", ) diff --git a/xlsx/workbook.mbt b/xlsx/workbook.mbt index b42d5058..bd241a25 100644 --- a/xlsx/workbook.mbt +++ b/xlsx/workbook.mbt @@ -183,6 +183,31 @@ pub fn Workbook::sheets(self : Workbook) -> ArrayView[Worksheet] { self.sheets } +///| +/// Returns the number of concrete worksheet cell records currently retained +/// by the workbook. Range metadata such as merges and validations is excluded. +pub fn Workbook::materialized_cell_count(self : Workbook) -> Int { + let mut count = 0 + for sheet in self.sheets { + count = count + sheet.cells().length() + } + count +} + +///| +/// Returns the retained row and column dimension records across all worksheets. +pub fn Workbook::materialized_row_column_dimension_count( + self : Workbook, +) -> Int { + let mut count = 0 + for sheet in self.sheets { + count = count + + sheet.row_dimensions.length() + + sheet.col_dimensions.length() + } + count +} + ///| pub fn Workbook::chart_sheets(self : Workbook) -> ArrayView[ChartSheet] { self.chart_sheets diff --git a/xlsx/worksheet_cell_index.mbt b/xlsx/worksheet_cell_index.mbt index 7d9188c5..8451d968 100644 --- a/xlsx/worksheet_cell_index.mbt +++ b/xlsx/worksheet_cell_index.mbt @@ -252,9 +252,11 @@ test "row customFormat boundaries control style inheritance after roundtrip" { ///| test "explicit column style zero remains distinct from an omitted style" { + let materialized_dimensions = Ref(0) + let dimension_work = Ref(0) let dimensions = parse_col_dimensions( "", - 1, + 1, materialized_dimensions, 10, dimension_work, 10, ) assert_eq(dimensions.get(1).map(dim => dim.style_id), Some(Some(0))) assert_true(dimensions.get(2) is None) diff --git a/xlsx/write.mbt b/xlsx/write.mbt index 4f600a1b..1048a9b7 100644 --- a/xlsx/write.mbt +++ b/xlsx/write.mbt @@ -1,6 +1,327 @@ ///| -fn write_workbook_protection_xml(protection : WorkbookProtection) -> String { - let sb = StringBuilder::new() +/// The remaining allowance for the next archive part. `maximum` is the tighter +/// of the per-entry ceiling and the aggregate archive space still available; +/// the other fields retain the policy-level error reported when it is crossed. +priv struct WritePartBudget { + maximum : Int + resource_kind : String + resource_limit : Int +} + +///| +/// A UTF-8-byte-counted XML builder. The ceiling is checked before appending +/// each fragment, so generated XML cannot first grow past the remaining archive +/// allowance. Escaped text and attributes are streamed directly into this +/// builder instead of allocating an expanded intermediate string. +priv struct LimitedXmlBuilder { + builder : StringBuilder + budget : WritePartBudget? + mut encoded_bytes : Int +} + +///| +fn LimitedXmlBuilder::new(budget : WritePartBudget?) -> LimitedXmlBuilder { + { builder: StringBuilder::new(), budget, encoded_bytes: 0 } +} + +///| +fn xml_utf8_length_within(value : StringView, maximum : Int) -> Int? { + let mut length = 0 + for character in value { + let scalar = character.to_int() + let width = if scalar <= 0x7F { + 1 + } else if scalar <= 0x7FF { + 2 + } else if scalar <= 0xFFFF { + 3 + } else { + 4 + } + if width > maximum - length { + return None + } + length = length + width + } + Some(length) +} + +///| +fn is_valid_xml_output_character(character : Char) -> Bool { + let scalar = character.to_int() + scalar == 0x09 || + scalar == 0x0A || + scalar == 0x0D || + (scalar >= 0x20 && scalar <= 0xD7FF) || + (scalar >= 0xE000 && scalar <= 0xFFFD) || + (scalar >= 0x10000 && scalar <= 0x10FFFF) +} + +///| +fn validate_xml_output(value : StringView) -> Unit raise XlsxError { + for character in value { + if !is_valid_xml_output_character(character) { + raise InvalidXml( + msg="generated XML contains forbidden code point \{character.to_int()}", + ) + } + } +} + +///| +fn LimitedXmlBuilder::write_view( + self : LimitedXmlBuilder, + value : StringView, +) -> Unit raise XlsxError { + validate_xml_output(value) + match self.budget { + Some(budget) => { + let remaining = budget.maximum - self.encoded_bytes + let fragment_bytes = match xml_utf8_length_within(value, remaining) { + Some(length) => length + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + self.builder.write_view(value) + self.encoded_bytes = self.encoded_bytes + fragment_bytes + } + None => self.builder.write_view(value) + } +} + +///| +fn LimitedXmlBuilder::write_char( + self : LimitedXmlBuilder, + value : Char, +) -> Unit raise XlsxError { + if !is_valid_xml_output_character(value) { + raise InvalidXml( + msg="generated XML contains forbidden code point \{value.to_int()}", + ) + } + let scalar = value.to_int() + let width = if scalar <= 0x7F { + 1 + } else if scalar <= 0x7FF { + 2 + } else if scalar <= 0xFFFF { + 3 + } else { + 4 + } + match self.budget { + Some(budget) if width > budget.maximum - self.encoded_bytes => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + _ => () + } + self.builder.write_char(value) + self.encoded_bytes = self.encoded_bytes + width +} + +///| +fn LimitedXmlBuilder::write_xml_text( + self : LimitedXmlBuilder, + value : StringView, +) -> Unit raise XlsxError { + let mut literal_start = 0 + for index in 0.. Some("&") + unit if unit == ('<' : UInt16) => Some("<") + unit if unit == ('>' : UInt16) => Some(">") + _ => None + } + match escaped { + Some(entity) => { + self.write_view(value[literal_start:index]) + self.write_view(entity) + literal_start = index + 1 + } + None => () + } + } + self.write_view(value[literal_start:]) +} + +///| +fn LimitedXmlBuilder::write_xml_attr( + self : LimitedXmlBuilder, + value : StringView, +) -> Unit raise XlsxError { + let mut literal_start = 0 + for index in 0.. Some("&") + unit if unit == ('<' : UInt16) => Some("<") + unit if unit == ('>' : UInt16) => Some(">") + unit if unit == ('"' : UInt16) => Some(""") + unit if unit == ('\'' : UInt16) => Some("'") + _ => None + } + match escaped { + Some(entity) => { + self.write_view(value[literal_start:index]) + self.write_view(entity) + literal_start = index + 1 + } + None => () + } + } + self.write_view(value[literal_start:]) +} + +///| +fn LimitedXmlBuilder::to_string(self : LimitedXmlBuilder) -> String { + self.builder.to_string() +} + +///| +/// Returns the unused portion of this builder's policy for a nested generator. +/// Passing this budget downstream prevents a child fragment from allocating up +/// to the whole part ceiling after its parent has already emitted content. +fn LimitedXmlBuilder::remaining_budget( + self : LimitedXmlBuilder, +) -> WritePartBudget? { + match self.budget { + Some(budget) => + Some({ + maximum: budget.maximum - self.encoded_bytes, + resource_kind: budget.resource_kind, + resource_limit: budget.resource_limit, + }) + None => None + } +} + +///| +/// Tracks XML fragments that must be retained before their parent can append +/// them. Each fragment receives only the allowance left after earlier retained +/// fragments, preventing an array of individually valid fragments from growing +/// beyond the parent part's remaining budget. +priv struct WritePartBudgetTracker { + budget : WritePartBudget? + mut encoded_bytes : Int +} + +///| +fn WritePartBudgetTracker::new( + budget : WritePartBudget?, +) -> WritePartBudgetTracker { + { budget, encoded_bytes: 0 } +} + +///| +fn WritePartBudgetTracker::remaining_budget( + self : WritePartBudgetTracker, +) -> WritePartBudget? { + match self.budget { + Some(budget) => + Some({ + maximum: budget.maximum - self.encoded_bytes, + resource_kind: budget.resource_kind, + resource_limit: budget.resource_limit, + }) + None => None + } +} + +///| +fn WritePartBudgetTracker::consume( + self : WritePartBudgetTracker, + value : StringView, +) -> Unit raise XlsxError { + match self.budget { + Some(budget) => { + let remaining = budget.maximum - self.encoded_bytes + let fragment_bytes = match xml_utf8_length_within(value, remaining) { + Some(length) => length + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + self.encoded_bytes = self.encoded_bytes + fragment_bytes + } + None => () + } +} + +///| +/// Parent allowance for a generated archive part that is assembled through +/// multiple simultaneously-retained strings. Every individual intermediate +/// remains subject to the per-entry ceiling, while their combined retained +/// bytes debit the aggregate space left after already-archived parts. +priv struct WriteCompositionBudgetTracker { + limits : WriteLimits? + archived_bytes : Int + mut retained_bytes : Int +} + +///| +fn WriteCompositionBudgetTracker::remaining_budget( + self : WriteCompositionBudgetTracker, +) -> WritePartBudget? { + match self.limits { + Some(limits) => { + let aggregate_remaining = limits.max_total_uncompressed_bytes - + self.archived_bytes - + self.retained_bytes + if limits.max_entry_uncompressed_bytes <= aggregate_remaining { + Some({ + maximum: limits.max_entry_uncompressed_bytes, + resource_kind: "entry_uncompressed_bytes", + resource_limit: limits.max_entry_uncompressed_bytes, + }) + } else { + Some({ + maximum: aggregate_remaining, + resource_kind: "total_uncompressed_bytes", + resource_limit: limits.max_total_uncompressed_bytes, + }) + } + } + None => None + } +} + +///| +fn WriteCompositionBudgetTracker::consume( + self : WriteCompositionBudgetTracker, + value : StringView, +) -> Unit raise XlsxError { + match self.remaining_budget() { + Some(budget) => { + let fragment_bytes = match xml_utf8_length_within(value, budget.maximum) { + Some(length) => length + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + self.retained_bytes = self.retained_bytes + fragment_bytes + } + None => () + } +} + +///| +fn write_workbook_protection_xml( + protection : WorkbookProtection, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" String { } if protection.algorithm_name != "" { sb.write_view(" workbookAlgorithmName=\"") - sb.write_view(escape_xml_attr(protection.algorithm_name)) + sb.write_xml_attr(protection.algorithm_name) sb.write_view("\"") } if protection.hash_value != "" { sb.write_view(" workbookHashValue=\"") - sb.write_view(escape_xml_attr(protection.hash_value)) + sb.write_xml_attr(protection.hash_value) sb.write_view("\"") } if protection.salt_value != "" { sb.write_view(" workbookSaltValue=\"") - sb.write_view(escape_xml_attr(protection.salt_value)) + sb.write_xml_attr(protection.salt_value) sb.write_view("\"") } if protection.spin_count > 0 { @@ -34,8 +355,12 @@ fn write_workbook_protection_xml(protection : WorkbookProtection) -> String { fn write_shared_strings_xml( values : Array[SharedStringEntry], total_count : Int, -) -> String { - fn write_rich_text_color(sb : StringBuilder, font : RichTextFont) -> Unit { + budget? : WritePartBudget, +) -> String raise XlsxError { + fn write_rich_text_color( + sb : LimitedXmlBuilder, + font : RichTextFont, + ) -> Unit raise XlsxError { let tint = match font.color_tint { Some(v) => if v == 0.0 { None } else { Some(v) } None => None @@ -72,7 +397,9 @@ fn write_shared_strings_xml( } else { normalized } - sb.write_view(" sb.write_view(" tint=\"\{v}\"") None => () @@ -83,7 +410,7 @@ fn write_shared_strings_xml( } } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -119,8 +446,11 @@ fn write_shared_strings_xml( sb.write_view("") } match font.underline { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } match font.size { @@ -136,8 +466,11 @@ fn write_shared_strings_xml( None => () } match font.family { - Some(family) => - sb.write_view("") + Some(family) => { + sb.write_view("") + } None => () } match font.color { @@ -150,15 +483,19 @@ fn write_shared_strings_xml( } } match font.vert_align { - Some(value) => - sb.write_view( - "", - ) + Some(value) => { + sb.write_view("") + } None => () } match font.scheme { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } sb.write_view("") @@ -172,7 +509,7 @@ fn write_shared_strings_xml( } else { sb.write_view("") } - sb.write_view(escape_xml_text(run.text)) + sb.write_xml_text(run.text) sb.write_view("\n") } sb.write_view(" \n") @@ -186,7 +523,7 @@ fn write_shared_strings_xml( } else { sb.write_view("") } - sb.write_view(escape_xml_text(entry.text)) + sb.write_xml_text(entry.text) sb.write_view("\n") } } @@ -214,8 +551,11 @@ fn collect_formula_cells(workbook : Workbook) -> Array[(Int, String)] { } ///| -fn write_calc_chain_xml(cells : Array[(Int, String)]) -> String { - let sb = StringBuilder::new() +fn write_calc_chain_xml( + cells : Array[(Int, String)], + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -223,7 +563,7 @@ fn write_calc_chain_xml(cells : Array[(Int, String)]) -> String { for cell in cells { let (sheet_id, reference) = cell sb.write_view(" \n") } sb.write_view("") @@ -401,7 +741,7 @@ fn two_cell_axis_to( } ///| -fn write_drawing_open(sb : StringBuilder) -> Unit { +fn write_drawing_open(sb : LimitedXmlBuilder) -> Unit raise XlsxError { sb.write_view("\n") sb.write_view( "\n", @@ -484,12 +824,13 @@ fn write_drawing_xml( slicers : Array[DrawingSlicer], preserved_anchors? : ArrayView[PreservedDrawingAnchor] = [], reserved_object_ids? : ArrayView[String] = [], + budget? : WritePartBudget, ) -> String raise XlsxError { let drawing_metrics = match sheet { Some(value) => drawing_metrics_for_sheet(value) None => drawing_metrics_for_sheet(Worksheet::new("Drawing")) } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) write_drawing_open(sb) let reserved_object_id_set : Set[String] = Set([]) for id in reserved_object_ids { @@ -611,11 +952,11 @@ fn write_drawing_xml( match item.hyperlink_rel_id { Some(link_id) => { sb.write_view(" \n") @@ -624,11 +965,11 @@ fn write_drawing_xml( } None => { sb.write_view(" \n") @@ -905,16 +1246,16 @@ fn write_drawing_xml( shape.name } sb.write_view(" \n") @@ -926,12 +1267,12 @@ fn write_drawing_xml( sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") if shape.fill_color != "" { let fill = shape.fill_color.replace_all(old="#", new="").to_upper() sb.write_view(" 0 { let alpha = (100 - shape.fill_transparency) * 1000 sb.write_view( @@ -952,7 +1293,7 @@ fn write_drawing_xml( } else { let line = shape.line_color.replace_all(old="#", new="").to_upper() sb.write_view(">\n") } } @@ -983,7 +1324,11 @@ fn write_drawing_xml( sb.write_view(" strike=\"sngStrike\"") } match font.underline { - Some(value) => sb.write_view(" u=\"\{escape_xml_attr(value)}\"") + Some(value) => { + sb.write_view(" u=\"") + sb.write_xml_attr(value) + sb.write_view("\"") + } None => () } match font.size { @@ -995,13 +1340,13 @@ fn write_drawing_xml( match font.family { Some(family) => { sb.write_view("") sb.write_view("") sb.write_view("") } None => () @@ -1010,7 +1355,7 @@ fn write_drawing_xml( Some(color) => { let normalized = color.replace_all(old="#", new="").to_upper() sb.write_view("") } None => () @@ -1027,7 +1372,7 @@ fn write_drawing_xml( } else { sb.write_view(" ") } - sb.write_view(escape_xml_text(text_value)) + sb.write_xml_text(text_value) sb.write_view("\n") sb.write_view(" \n") sb.write_view(" \n") @@ -1186,17 +1531,17 @@ fn write_drawing_xml( sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") @@ -1213,7 +1558,7 @@ fn write_drawing_xml( sb.write_view( " \n") sb.write_view(" \n") sb.write_view(" \n") @@ -1223,17 +1568,17 @@ fn write_drawing_xml( sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") @@ -1259,17 +1604,13 @@ fn write_drawing_xml( " \n", ) sb.write_view(" ") - sb.write_view( - escape_xml_text( - "This shape represents a table slicer. Table slicers are not supported in this version of Excel.", - ), + sb.write_xml_text( + "This shape represents a table slicer. Table slicers are not supported in this version of Excel.", ) sb.write_view("\n") sb.write_view(" ") - sb.write_view( - escape_xml_text( - "If the shape was modified in an earlier version of Excel, or if the workbook was saved in Excel 2007 or earlier, the slicer can't be used.", - ), + sb.write_xml_text( + "If the shape was modified in an earlier version of Excel, or if the workbook was saved in Excel 2007 or earlier, the slicer can't be used.", ) sb.write_view("\n") sb.write_view(" \n") @@ -1344,7 +1685,7 @@ fn write_drawing_xml( }) } ordered.sort_by(compare_ordered_drawing_anchor) - let output = StringBuilder::new() + let output = LimitedXmlBuilder::new(budget) write_drawing_open(output) for anchor in ordered { output.write_view(" ") @@ -1356,13 +1697,16 @@ fn write_drawing_xml( } ///| -fn write_sheet_pr_xml(options : SheetPropsOptions) -> String { - let sb = StringBuilder::new() +fn write_sheet_pr_xml( + options : SheetPropsOptions, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" { sb.write_view(" codeName=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -1400,7 +1744,7 @@ fn write_sheet_pr_xml(options : SheetPropsOptions) -> String { match options.tab_color_rgb { Some(value) => { sb.write_view(" rgb=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -1444,8 +1788,11 @@ fn write_sheet_pr_xml(options : SheetPropsOptions) -> String { } ///| -fn write_sheet_format_pr_xml(options : SheetPropsOptions) -> String { - let sb = StringBuilder::new() +fn write_sheet_format_pr_xml( + options : SheetPropsOptions, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" sb.write_view(" baseColWidth=\"\{value}\"") @@ -1480,11 +1827,14 @@ fn write_sheet_format_pr_xml(options : SheetPropsOptions) -> String { } ///| -fn write_sheet_views_xml(sheet : Worksheet) -> String? raise XlsxError { +fn write_sheet_views_xml( + sheet : Worksheet, + budget? : WritePartBudget, +) -> String? raise XlsxError { if sheet.sheet_views.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") for view in sheet.sheet_views { sb.write_view(" String? raise XlsxError { match view.top_left_cell { Some(value) => { sb.write_view(" topLeftCell=\"") - sb.write_view(escape_xml_attr(normalize_cell_ref_for_write(value))) + sb.write_xml_attr(normalize_cell_ref_for_write(value)) sb.write_view("\"") } None => () @@ -1527,7 +1877,7 @@ fn write_sheet_views_xml(sheet : Worksheet) -> String? raise XlsxError { match view.view { Some(value) => { sb.write_view(" view=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -1550,19 +1900,17 @@ fn write_sheet_views_xml(sheet : Worksheet) -> String? raise XlsxError { sb.write_view(" String? raise XlsxError { sb.write_view(" \n") @@ -1605,8 +1949,11 @@ fn write_sheet_views_xml(sheet : Worksheet) -> String? raise XlsxError { } ///| -fn write_chartsheet_xml(drawing_rel_id : Int) -> String { - let sb = StringBuilder::new() +fn write_chartsheet_xml( + drawing_rel_id : Int, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -1751,6 +2098,7 @@ fn write_worksheet_xml( slicer_rel_ids_x15 : ArrayView[Int], legacy_drawing_rel_id : Int?, legacy_drawing_hf_rel_id : Int?, + budget? : WritePartBudget, ) -> String raise XlsxError { fn write_worksheet_ext_lst_xml( slicer_rel_ids_x14 : ArrayView[Int], @@ -1758,10 +2106,12 @@ fn write_worksheet_xml( sparkline_groups : ArrayView[SparklineGroup], x14_data_bars : Map[String, X14DataBarProps], unknown_ext_blocks : ArrayView[String], - ) -> String? { + budget : WritePartBudget?, + ) -> String? raise XlsxError { fn write_x14_conditional_formattings_ext_block_xml( x14_data_bars : Map[String, X14DataBarProps], - ) -> String? { + budget : WritePartBudget?, + ) -> String? raise XlsxError { let by_sqref : Map[String, Array[(String, X14DataBarProps)]] = Map([]) for id, props in x14_data_bars { if props.bar_direction == "" && @@ -1780,7 +2130,7 @@ fn write_worksheet_xml( if by_sqref.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view( " \n", ) @@ -1792,12 +2142,12 @@ fn write_worksheet_xml( for entry in entries { let (id, props) = entry sb.write_view(" \n") sb.write_view(" \n") } sb.write_view(" ") - sb.write_view(escape_xml_text(sqref)) + sb.write_xml_text(sqref) sb.write_view("\n") sb.write_view(" \n") } @@ -1831,10 +2181,15 @@ fn write_worksheet_xml( Some(sb.to_string()) } - let cond_fmt_ext = write_x14_conditional_formattings_ext_block_xml( - x14_data_bars, - ) - let has_conditional = cond_fmt_ext is Some(_) + let mut has_conditional = false + for _, props in x14_data_bars { + if props.bar_direction != "" || + props.bar_solid || + props.bar_border_color != "" { + has_conditional = true + break + } + } let has_slicers = slicer_rel_ids_x14.length() > 0 || slicer_rel_ids_x15.length() > 0 let has_sparklines = sparkline_groups.length() > 0 @@ -1843,17 +2198,29 @@ fn write_worksheet_xml( return None } if has_sparklines && !has_slicers && !has_conditional && !has_unknown { - return Some(write_sparkline_ext_xml(sheet.name(), sparkline_groups)) + return Some( + write_sparkline_ext_xml(sheet.name(), sparkline_groups, budget?), + ) } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") - match cond_fmt_ext { - Some(block) => sb.write_view(block) - None => () + if has_conditional { + match + write_x14_conditional_formattings_ext_block_xml( + x14_data_bars, + sb.remaining_budget(), + ) { + Some(block) => sb.write_view(block) + None => () + } } if has_sparklines { sb.write_view( - write_sparkline_ext_block_xml(sheet.name(), sparkline_groups), + write_sparkline_ext_block_xml( + sheet.name(), + sparkline_groups, + budget?=sb.remaining_budget(), + ), ) } if slicer_rel_ids_x14.length() > 0 { @@ -1900,7 +2267,7 @@ fn write_worksheet_xml( Some(sb.to_string()) } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") let needs_rels = drawing_rel_id is Some(_) || table_rel_ids.length() > 0 || @@ -1936,26 +2303,30 @@ fn write_worksheet_xml( match sheet.sheet_props { Some(options) => if sheet_props_has_sheet_pr(options) { - sb.write_view(write_sheet_pr_xml(options)) + sb.write_view( + write_sheet_pr_xml(options, budget?=sb.remaining_budget()), + ) } None => () } match worksheet_dimension(sheet) { Some(value) => { sb.write_view(" \n") } None => () } - match write_sheet_views_xml(sheet) { + match write_sheet_views_xml(sheet, budget?=sb.remaining_budget()) { Some(value) => sb.write_view(value) None => () } match sheet.sheet_props { Some(options) => if sheet_props_has_sheet_format(options) { - sb.write_view(write_sheet_format_pr_xml(options)) + sb.write_view( + write_sheet_format_pr_xml(options, budget?=sb.remaining_budget()), + ) } None => () } @@ -2019,7 +2390,7 @@ fn write_worksheet_xml( sb.write_view(">\n") for cell in row_entries { sb.write_view(" { sb.write_view(" ref=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -2058,13 +2429,13 @@ fn write_worksheet_xml( None => () } sb.write_view(">") - sb.write_view(escape_xml_text(formula)) + sb.write_xml_text(formula) sb.write_view("") if cell.formula_value_present { sb.write_view("") match cell.value_type { - String => sb.write_view(escape_xml_text(cell.value)) - Error => sb.write_view(escape_xml_text(cell.value)) + String => sb.write_xml_text(cell.value) + Error => sb.write_xml_text(cell.value) _ => sb.write_view(cell.value) } sb.write_view("") @@ -2095,7 +2466,7 @@ fn write_worksheet_xml( } Error => { sb.write_view(" t=\"e\">") - sb.write_view(escape_xml_text(cell.value)) + sb.write_xml_text(cell.value) sb.write_view("\n") } Number => { @@ -2110,18 +2481,24 @@ fn write_worksheet_xml( } sb.write_view(" \n") match sheet.sheet_protection() { - Some(protection) => sb.write_view(write_sheet_protection_xml(protection)) + Some(protection) => + sb.write_view( + write_sheet_protection_xml(protection, budget?=sb.remaining_budget()), + ) None => () } match sheet.auto_filter() { - Some(filter) => sb.write_view(write_auto_filter_xml(filter)) + Some(filter) => + sb.write_view( + write_auto_filter_xml(filter, budget?=sb.remaining_budget()), + ) None => () } if sheet.merged_cells().length() > 0 { sb.write_view(" \n") for range_ref in sheet.merged_cells() { sb.write_view(" \n") } sb.write_view(" \n") @@ -2153,7 +2530,7 @@ fn write_worksheet_xml( wrote = true } sb.write_view(" { sb.write_view(" location=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () } } else { sb.write_view(" location=\"") - sb.write_view(escape_xml_attr(link.target)) + sb.write_xml_attr(link.target) sb.write_view("\"") } match link.display { Some(value) => { sb.write_view(" display=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -2184,7 +2561,7 @@ fn write_worksheet_xml( match link.tooltip { Some(value) => { sb.write_view(" tooltip=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -2197,32 +2574,52 @@ fn write_worksheet_xml( } match sheet.page_margins { Some(margins) => { - match write_print_options_xml(margins) { + match write_print_options_xml(margins, budget?=sb.remaining_budget()) { Some(xml) => sb.write_view(xml) None => () } let merged = page_margins_with_defaults(Some(margins)) - sb.write_view(write_page_margins_xml(merged)) + sb.write_view( + write_page_margins_xml(merged, budget?=sb.remaining_budget()), + ) } None => () } match sheet.page_layout { - Some(layout) => sb.write_view(write_page_setup_xml(layout)) + Some(layout) => + sb.write_view(write_page_setup_xml(layout, budget?=sb.remaining_budget())) None => () } match sheet.header_footer() { - Some(options) => sb.write_view(write_header_footer_xml(options)) + Some(options) => + sb.write_view( + write_header_footer_xml(options, budget?=sb.remaining_budget()), + ) None => () } - match write_page_breaks_xml("rowBreaks", sheet.row_breaks()) { + match + write_page_breaks_xml( + "rowBreaks", + sheet.row_breaks(), + budget?=sb.remaining_budget(), + ) { Some(xml) => sb.write_view(xml) None => () } - match write_page_breaks_xml("colBreaks", sheet.col_breaks()) { + match + write_page_breaks_xml( + "colBreaks", + sheet.col_breaks(), + budget?=sb.remaining_budget(), + ) { Some(xml) => sb.write_view(xml) None => () } - match write_ignored_errors_xml(sheet.ignored_errors()) { + match + write_ignored_errors_xml( + sheet.ignored_errors(), + budget?=sb.remaining_budget(), + ) { Some(value) => sb.write_view(value) None => () } @@ -2257,6 +2654,7 @@ fn write_worksheet_xml( sheet.sparkline_groups(), sheet.x14_data_bars, sheet.unknown_ext_blocks, + sb.remaining_budget(), ) { Some(xml) => sb.write_view(xml) None => () @@ -2345,42 +2743,31 @@ let default_theme_xml = #| ///| -fn write_theme_xml(theme_colors : Array[String]?) -> String { +fn write_theme_xml( + theme_colors : Array[String]?, + budget? : WritePartBudget, +) -> String raise XlsxError { + fn write_theme_color( + sb : LimitedXmlBuilder, + value : StringView, + ) -> Unit raise XlsxError { + let normalized = if value.length() == 8 && value.has_prefix("FF") { + value[2:] + } else { + value + } + for character in normalized { + if character != '#' { + sb.write_char(character.to_ascii_uppercase()) + } + } + } + match theme_colors { Some(colors) => if colors.length() >= 10 { // Order matches parse_theme_colors: lt1, dk1, lt2, dk2, accent1..accent6. - let lt1 = normalize_rgb_hex(colors[0]) - .replace_all(old="#", new="") - .to_upper() - let dk1 = normalize_rgb_hex(colors[1]) - .replace_all(old="#", new="") - .to_upper() - let lt2 = normalize_rgb_hex(colors[2]) - .replace_all(old="#", new="") - .to_upper() - let dk2 = normalize_rgb_hex(colors[3]) - .replace_all(old="#", new="") - .to_upper() - let a1 = normalize_rgb_hex(colors[4]) - .replace_all(old="#", new="") - .to_upper() - let a2 = normalize_rgb_hex(colors[5]) - .replace_all(old="#", new="") - .to_upper() - let a3 = normalize_rgb_hex(colors[6]) - .replace_all(old="#", new="") - .to_upper() - let a4 = normalize_rgb_hex(colors[7]) - .replace_all(old="#", new="") - .to_upper() - let a5 = normalize_rgb_hex(colors[8]) - .replace_all(old="#", new="") - .to_upper() - let a6 = normalize_rgb_hex(colors[9]) - .replace_all(old="#", new="") - .to_upper() - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -2388,34 +2775,34 @@ fn write_theme_xml(theme_colors : Array[String]?) -> String { sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") sb.write_view( @@ -2492,10 +2879,155 @@ fn write_theme_xml(theme_colors : Array[String]?) -> String { } } +///| +/// Archive builder used by bounded writes. Limits are checked before +/// `Archive::add` takes its defensive payload copy, so aggregate growth cannot +/// first materialize an oversized retained archive and only fail at ZIP output. +priv struct WriteArchiveBuilder { + archive : @zip.Archive + limits : WriteLimits? + mut entry_count : Int + mut total_uncompressed_bytes : Int +} + +///| +fn WriteArchiveBuilder::new(limits : WriteLimits?) -> WriteArchiveBuilder { + { + archive: @zip.Archive::new(), + limits, + entry_count: 0, + total_uncompressed_bytes: 0, + } +} + +///| +fn WriteArchiveBuilder::next_composition_budget( + self : WriteArchiveBuilder, +) -> WriteCompositionBudgetTracker raise XlsxError { + match self.limits { + Some(limits) if self.entry_count >= limits.max_archive_entries => + raise ResourceLimitExceeded( + kind="entry_count", + limit=limits.max_archive_entries, + actual=bounded_actual_above_limit(limits.max_archive_entries), + ) + _ => () + } + { + limits: self.limits, + archived_bytes: self.total_uncompressed_bytes, + retained_bytes: 0, + } +} + +///| +fn WriteArchiveBuilder::next_part_budget( + self : WriteArchiveBuilder, +) -> WritePartBudget? raise XlsxError { + match self.limits { + Some(limits) => { + if self.entry_count >= limits.max_archive_entries { + raise ResourceLimitExceeded( + kind="entry_count", + limit=limits.max_archive_entries, + actual=bounded_actual_above_limit(limits.max_archive_entries), + ) + } + let aggregate_remaining = limits.max_total_uncompressed_bytes - + self.total_uncompressed_bytes + if limits.max_entry_uncompressed_bytes <= aggregate_remaining { + Some({ + maximum: limits.max_entry_uncompressed_bytes, + resource_kind: "entry_uncompressed_bytes", + resource_limit: limits.max_entry_uncompressed_bytes, + }) + } else { + Some({ + maximum: aggregate_remaining, + resource_kind: "total_uncompressed_bytes", + resource_limit: limits.max_total_uncompressed_bytes, + }) + } + } + None => None + } +} + +///| +/// Adds an already-retained text part after checking its exact UTF-8 length and +/// before allocating the encoded byte buffer. +fn WriteArchiveBuilder::add_text( + self : WriteArchiveBuilder, + name : String, + text : StringView, +) -> Unit raise XlsxError { + match self.next_part_budget() { + Some(budget) => + match xml_utf8_length_within(text, budget.maximum) { + Some(_) => () + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + None => () + } + self.add(name, @encoding/utf8.encode(text)) +} + +///| +fn WriteArchiveBuilder::add( + self : WriteArchiveBuilder, + name : String, + data : BytesView, +) -> Unit raise XlsxError { + match self.limits { + Some(limits) => { + if self.entry_count >= limits.max_archive_entries { + raise ResourceLimitExceeded( + kind="entry_count", + limit=limits.max_archive_entries, + actual=bounded_actual_above_limit(limits.max_archive_entries), + ) + } + let next_entry_count = self.entry_count + 1 + let size = data.length() + if size > limits.max_entry_uncompressed_bytes { + raise ResourceLimitExceeded( + kind="entry_uncompressed_bytes", + limit=limits.max_entry_uncompressed_bytes, + actual=size, + ) + } + if size > + limits.max_total_uncompressed_bytes - self.total_uncompressed_bytes { + raise ResourceLimitExceeded( + kind="total_uncompressed_bytes", + limit=limits.max_total_uncompressed_bytes, + actual=bounded_actual_above_limit(limits.max_total_uncompressed_bytes), + ) + } + let next_total = self.total_uncompressed_bytes + size + self.entry_count = next_entry_count + self.total_uncompressed_bytes = next_total + } + None => () + } + self.archive.add(name, data) +} + +///| +fn WriteArchiveBuilder::finish(self : WriteArchiveBuilder) -> @zip.Archive { + self.archive +} + ///| fn write_with_io( workbook : Workbook, io_context : WorkbookIOContext, + limits? : WriteLimits, ) -> Bytes raise XlsxError { let (shared_strings, string_map, rich_text_map, total_count) = collect_shared_strings( workbook, @@ -2553,11 +3085,21 @@ fn write_with_io( } None => () } - let archive = @zip.Archive::new() - archive.add( - root_rels_part_path, - @encoding/utf8.encode(manifest.root_rels_xml()), - ) + let archive = WriteArchiveBuilder::new(limits) + let root_rels_xml = match archive.next_part_budget() { + Some(budget) => + match manifest.root_rels_xml_limited(budget.maximum) { + Some(xml) => xml + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + None => manifest.root_rels_xml() + } + archive.add_text(root_rels_part_path, root_rels_xml) let tables_by_sheet : Array[Array[(Int, Table)]] = [] let mut next_table_id = 1 for sheet in workbook.sheets() { @@ -2745,13 +3287,15 @@ fn write_with_io( workbook_pivot_cache_rel_entries.push((cache_id, rel_id)) } } - archive.add( + archive.add_text( workbook_part_path, - @encoding/utf8.encode( - write_workbook_xml( - workbook, chartsheet_rel_start, workbook_pivot_cache_rel_entries, workbook_slicer_cache_rel_ids_x14, - workbook_slicer_cache_rel_ids_x15, - ), + write_workbook_xml( + workbook, + chartsheet_rel_start, + workbook_pivot_cache_rel_entries, + workbook_slicer_cache_rel_ids_x14, + workbook_slicer_cache_rel_ids_x15, + budget?=archive.next_part_budget(), ), ) let added_extensions : Map[String, Bool] = Map([]) @@ -2771,10 +3315,11 @@ fn write_with_io( part.content_type, ) if part.relationships.length() > 0 { - archive.add( + archive.add_text( rels_path_for_part(part.path), - @encoding/utf8.encode( - write_preserved_drawing_rels_xml(part.relationships), + write_preserved_drawing_rels_xml( + part.relationships, + budget?=archive.next_part_budget(), ), ) } @@ -2852,19 +3397,10 @@ fn write_with_io( } } let comment_targets : Array[String] = [] - let comment_parts : Array[(Int, String)] = [] let comment_xml = if sheet.comments().length() > 0 { - Some(write_comments_xml(sheet.comments())) - } else { - None - } - let comment_vml_xml = if sheet.comments().length() > 0 { - Some(write_comments_vml(sheet_id, sheet.comments())) - } else { - None - } - let form_control_vml_xml = if sheet.form_controls().length() > 0 { - Some(write_form_controls_vml(sheet_id, sheet, sheet.form_controls())) + Some( + write_comments_xml(sheet.comments(), budget?=archive.next_part_budget()), + ) } else { None } @@ -2873,7 +3409,11 @@ fn write_with_io( let comment_id = comment_index comment_index = comment_index + 1 comment_targets.push(comment_rel_target(comment_id)) - comment_parts.push((comment_id, xml)) + archive.add_text(comment_part_path(comment_id), xml) + manifest.add_content_override( + content_override_path(comment_part_path(comment_id)), + ct_comments, + ) } None => () } @@ -2881,16 +3421,50 @@ fn write_with_io( rel_id = rel_id + comment_targets.length() } let vml_targets : Array[String] = [] - let vml_parts : Array[(Int, String)] = [] - let vml_image_rels : Array[(Int, Array[String])] = [] let base_vml = match sheet.vml_drawing_xml() { Some(xml) => Some(xml) None => None } + // Comment/control VML can require several retained intermediates before + // the final part is known. Give the whole composition one parent budget, + // debit each generated intermediate immediately, and archive the final + // part below before beginning any other generated VML. + let legacy_vml_budget = archive.next_composition_budget() + let comment_vml_xml = if sheet.comments().length() > 0 { + let xml = write_comments_vml( + sheet_id, + sheet.comments(), + budget?=legacy_vml_budget.remaining_budget(), + ) + legacy_vml_budget.consume(xml) + Some(xml) + } else { + None + } + let form_control_vml_xml = if sheet.form_controls().length() > 0 { + let xml = write_form_controls_vml( + sheet_id, + sheet, + sheet.form_controls(), + budget?=legacy_vml_budget.remaining_budget(), + ) + legacy_vml_budget.consume(xml) + Some(xml) + } else { + None + } let merged_vml = match base_vml { Some(xml) => match form_control_vml_xml { - Some(control_xml) => Some(merge_vml_drawings(xml, control_xml)) + Some(control_xml) => { + let merged = merge_vml_drawings( + xml, + control_xml, + budget?=legacy_vml_budget.remaining_budget(), + ) + legacy_vml_budget.consume(merged) + Some(merged) + } None => Some(xml) } None => form_control_vml_xml @@ -2898,7 +3472,15 @@ fn write_with_io( let legacy_drawing_xml = match merged_vml { Some(xml) => match comment_vml_xml { - Some(comment_xml) => Some(merge_vml_drawings(xml, comment_xml)) + Some(comment_xml) => { + let merged = merge_vml_drawings( + xml, + comment_xml, + budget?=legacy_vml_budget.remaining_budget(), + ) + legacy_vml_budget.consume(merged) + Some(merged) + } None => Some(xml) } None => comment_vml_xml @@ -2910,14 +3492,25 @@ fn write_with_io( let vml_id = vml_index vml_index = vml_index + 1 vml_targets.push(vml_rel_target(vml_id)) - vml_parts.push((vml_id, xml)) + archive.add_text(vml_part_path(vml_id), xml) + manifest.add_content_override( + content_override_path(vml_part_path(vml_id)), + ct_vml, + ) Some(id) } None => None } let header_footer_images = sheet.header_footer_images + let header_footer_vml_budget = archive.next_composition_budget() let header_footer_vml = if header_footer_images.length() > 0 { - Some(write_header_footer_vml(sheet_id, header_footer_images)) + let xml = write_header_footer_vml( + sheet_id, + header_footer_images, + budget?=header_footer_vml_budget.remaining_budget(), + ) + header_footer_vml_budget.consume(xml) + Some(xml) } else { None } @@ -2937,17 +3530,41 @@ fn write_with_io( Some(xml) => match header_footer_vml { Some(extra_xml) => { - let filtered = filter_vml_shapes_by_id(xml, header_footer_shape_ids) - Some( - merge_vml_drawings_with_shapetype( - filtered, extra_xml, "_x0000_t75", - ), + let filtered = filter_vml_shapes_by_id( + xml, + header_footer_shape_ids, + budget?=header_footer_vml_budget.remaining_budget(), + ) + header_footer_vml_budget.consume(filtered) + let merged = merge_vml_drawings_with_shapetype( + filtered, + extra_xml, + "_x0000_t75", + budget?=header_footer_vml_budget.remaining_budget(), ) + header_footer_vml_budget.consume(merged) + Some(merged) } None => Some(xml) } None => header_footer_vml } + let legacy_drawing_hf_ids : (Int, Int)? = match legacy_drawing_hf_xml { + Some(xml) => { + let id = rel_id + rel_id = rel_id + 1 + let vml_id = vml_index + vml_index = vml_index + 1 + vml_targets.push(vml_rel_target(vml_id)) + archive.add_text(vml_part_path(vml_id), xml) + manifest.add_content_override( + content_override_path(vml_part_path(vml_id)), + ct_vml, + ) + Some((id, vml_id)) + } + None => None + } let header_footer_image_targets : Array[String] = [] if header_footer_images.length() > 0 { for image in header_footer_images { @@ -2961,19 +3578,18 @@ fn write_with_io( } } } - let legacy_drawing_hf_rel_id = match legacy_drawing_hf_xml { - Some(xml) => { - let id = rel_id - rel_id = rel_id + 1 - let vml_id = vml_index - vml_index = vml_index + 1 - vml_targets.push(vml_rel_target(vml_id)) - vml_parts.push((vml_id, xml)) - if header_footer_image_targets.length() > 0 { - vml_image_rels.push((vml_id, header_footer_image_targets)) - } - Some(id) + match legacy_drawing_hf_ids { + Some((_id, vml_id)) if header_footer_image_targets.length() > 0 => { + let vml_rels_xml = write_vml_rels_xml( + header_footer_image_targets, + budget?=archive.next_part_budget(), + ) + archive.add_text(vml_rels_part_path(vml_id), vml_rels_xml) } + _ => () + } + let legacy_drawing_hf_rel_id = match legacy_drawing_hf_ids { + Some((id, _vml_id)) => Some(id) None => None } let picture_targets : Array[String] = [] @@ -3010,8 +3626,9 @@ fn write_with_io( slicer_rel_ids_x15, legacy_drawing_rel_id, legacy_drawing_hf_rel_id, + budget?=archive.next_part_budget(), ) - archive.add(worksheet_part_path(sheet_id), @encoding/utf8.encode(sheet_xml)) + archive.add_text(worksheet_part_path(sheet_id), sheet_xml) if drawing_rel_id is Some(_) || table_targets.length() > 0 || pivot_targets.length() > 0 || @@ -3025,18 +3642,26 @@ fn write_with_io( None => None } let sheet_rels_xml = write_sheet_rels_xml( - drawing_target, table_targets, pivot_targets, slicer_targets, hyperlink_targets, - comment_targets, vml_targets, picture_targets, - ) - archive.add( - worksheet_rels_part_path(sheet_id), - @encoding/utf8.encode(sheet_rels_xml), + drawing_target, + table_targets, + pivot_targets, + slicer_targets, + hyperlink_targets, + comment_targets, + vml_targets, + picture_targets, + budget?=archive.next_part_budget(), ) + archive.add_text(worksheet_rels_part_path(sheet_id), sheet_rels_xml) } for entry in sheet_tables { let (table_id, table) = entry - let table_xml = write_table_xml(table_id, table) - archive.add(table_part_path(table_id), @encoding/utf8.encode(table_xml)) + let table_xml = write_table_xml( + table_id, + table, + budget?=archive.next_part_budget(), + ) + archive.add_text(table_part_path(table_id), table_xml) manifest.add_content_override( content_override_path(table_part_path(table_id)), ct_table, @@ -3044,31 +3669,29 @@ fn write_with_io( } for entry in sheet_pivots { let (pivot_id, cache_id, pivot) = entry - archive.add( - pivot_table_part_path(pivot_id), - @encoding/utf8.encode(pivot.table_xml), - ) + archive.add_text(pivot_table_part_path(pivot_id), pivot.table_xml) manifest.add_content_override( content_override_path(pivot_table_part_path(pivot_id)), ct_pivot_table, ) - let pivot_rels_xml = write_pivot_table_rels_xml(cache_id) - archive.add( - pivot_table_rels_part_path(pivot_id), - @encoding/utf8.encode(pivot_rels_xml), + let pivot_rels_xml = write_pivot_table_rels_xml( + cache_id, + budget?=archive.next_part_budget(), ) + archive.add_text(pivot_table_rels_part_path(pivot_id), pivot_rels_xml) let cache_definition_xml = match pivot_cache_slicer_id_by_cache_id.get(cache_id) { Some(slicer_pivot_cache_id) => ensure_pivot_cache_slicer_id( pivot.cache_definition_xml, slicer_pivot_cache_id, + budget?=archive.next_part_budget(), ) None => pivot.cache_definition_xml } - archive.add( + archive.add_text( pivot_cache_definition_part_path(cache_id), - @encoding/utf8.encode(cache_definition_xml), + cache_definition_xml, ) manifest.add_content_override( content_override_path(pivot_cache_definition_part_path(cache_id)), @@ -3076,18 +3699,18 @@ fn write_with_io( ) match pivot.cache_records_xml { Some(records) => { - archive.add( - pivot_cache_records_part_path(cache_id), - @encoding/utf8.encode(records), - ) + archive.add_text(pivot_cache_records_part_path(cache_id), records) manifest.add_content_override( content_override_path(pivot_cache_records_part_path(cache_id)), ct_pivot_cache_records, ) - let cache_rels_xml = write_pivot_cache_rels_xml(cache_id) - archive.add( + let cache_rels_xml = write_pivot_cache_rels_xml( + cache_id, + budget?=archive.next_part_budget(), + ) + archive.add_text( pivot_cache_definition_rels_part_path(cache_id), - @encoding/utf8.encode(cache_rels_xml), + cache_rels_xml, ) } None => () @@ -3095,11 +3718,11 @@ fn write_with_io( } match pivot_slicer_part_id { Some(part_id) => { - let slicers_xml = write_slicers_part_xml(pivot_slicers) - archive.add( - slicer_part_path(part_id), - @encoding/utf8.encode(slicers_xml), + let slicers_xml = write_slicers_part_xml( + pivot_slicers, + budget?=archive.next_part_budget(), ) + archive.add_text(slicer_part_path(part_id), slicers_xml) manifest.add_content_override( content_override_path(slicer_part_path(part_id)), ct_slicer, @@ -3109,11 +3732,11 @@ fn write_with_io( } match table_slicer_part_id { Some(part_id) => { - let slicers_xml = write_slicers_part_xml(table_slicers) - archive.add( - slicer_part_path(part_id), - @encoding/utf8.encode(slicers_xml), + let slicers_xml = write_slicers_part_xml( + table_slicers, + budget?=archive.next_part_budget(), ) + archive.add_text(slicer_part_path(part_id), slicers_xml) manifest.add_content_override( content_override_path(slicer_part_path(part_id)), ct_slicer, @@ -3121,30 +3744,6 @@ fn write_with_io( } None => () } - for part in comment_parts { - let (comment_id, xml) = part - archive.add(comment_part_path(comment_id), @encoding/utf8.encode(xml)) - manifest.add_content_override( - content_override_path(comment_part_path(comment_id)), - ct_comments, - ) - } - for part in vml_parts { - let (vml_id, xml) = part - archive.add(vml_part_path(vml_id), @encoding/utf8.encode(xml)) - manifest.add_content_override( - content_override_path(vml_part_path(vml_id)), - ct_vml, - ) - } - for part in vml_image_rels { - let (vml_id, targets) = part - let vml_rels_xml = write_vml_rels_xml(targets) - archive.add( - vml_rels_part_path(vml_id), - @encoding/utf8.encode(vml_rels_xml), - ) - } if !has_drawing { continue } @@ -3191,7 +3790,7 @@ fn write_with_io( for chart in sheet.charts() { let file_name = "chart\{chart_index}.xml" chart_index = chart_index + 1 - archive.add(chart_part_path(file_name), @encoding/utf8.encode(chart.xml)) + archive.add_text(chart_part_path(file_name), chart.xml) let (chart_rel_id, next_rel_id) = allocate_drawing_relationship_id( rel_id, reserved_relationship_ids, ) @@ -3239,18 +3838,17 @@ fn write_with_io( drawing_slicers, preserved_anchors=preserved_drawing.anchors, reserved_object_ids=preserved_drawing.object_ids, + budget?=archive.next_part_budget(), ) - archive.add(drawing_part_path(sheet_id), @encoding/utf8.encode(drawing_xml)) + archive.add_text(drawing_part_path(sheet_id), drawing_xml) let drawing_rels_xml = write_drawing_rels_xml( image_targets, chart_targets, drawing_hyperlinks, preserved_relationships=preserved_drawing.drawing_relationships, + budget?=archive.next_part_budget(), ) - archive.add( - drawing_rels_part_path(sheet_id), - @encoding/utf8.encode(drawing_rels_xml), - ) + archive.add_text(drawing_rels_part_path(sheet_id), drawing_rels_xml) } for entry in resolved_slicer_caches { let cache_id = entry.cache_id @@ -3262,6 +3860,7 @@ fn write_with_io( table_id, column, entry.item_desc, + budget?=archive.next_part_budget(), ) Pivot(pivot_cache_id, pivot_sheet_id, pivot_name) => write_slicer_cache_xml_pivot( @@ -3271,12 +3870,10 @@ fn write_with_io( pivot_name, pivot_cache_id, entry.item_desc, + budget?=archive.next_part_budget(), ) } - archive.add( - slicer_cache_part_path(cache_id), - @encoding/utf8.encode(cache_xml), - ) + archive.add_text(slicer_cache_part_path(cache_id), cache_xml) manifest.add_content_override( content_override_path(slicer_cache_part_path(cache_id)), ct_slicer_cache, @@ -3286,10 +3883,7 @@ fn write_with_io( let sheet_id = i + 1 let chart_name = "chart\{chart_index}.xml" chart_index = chart_index + 1 - archive.add( - chart_part_path(chart_name), - @encoding/utf8.encode(sheet.chart_xml()), - ) + archive.add_text(chart_part_path(chart_name), sheet.chart_xml()) manifest.add_content_override( content_override_path(chart_part_path(chart_name)), ct_chart, @@ -3325,24 +3919,24 @@ fn write_with_io( drawing_charts, [], [], + budget?=archive.next_part_budget(), ) - archive.add( - drawing_part_path(drawing_id), - @encoding/utf8.encode(drawing_xml), - ) + archive.add_text(drawing_part_path(drawing_id), drawing_xml) let chart_targets : Array[(Int, String)] = [ (1, chart_rel_target(chart_name)), ] - let drawing_rels_xml = write_drawing_rels_xml([], chart_targets, []) - archive.add( - drawing_rels_part_path(drawing_id), - @encoding/utf8.encode(drawing_rels_xml), + let drawing_rels_xml = write_drawing_rels_xml( + [], + chart_targets, + [], + budget?=archive.next_part_budget(), ) - let chartsheet_xml = write_chartsheet_xml(1) - archive.add( - chartsheet_part_path(sheet_id), - @encoding/utf8.encode(chartsheet_xml), + archive.add_text(drawing_rels_part_path(drawing_id), drawing_rels_xml) + let chartsheet_xml = write_chartsheet_xml( + 1, + budget?=archive.next_part_budget(), ) + archive.add_text(chartsheet_part_path(sheet_id), chartsheet_xml) let chartsheet_rels_xml = write_sheet_rels_xml( Some(drawing_rel_target(drawing_id)), [], @@ -3352,61 +3946,68 @@ fn write_with_io( [], [], [], + budget?=archive.next_part_budget(), ) - archive.add( - chartsheet_rels_part_path(sheet_id), - @encoding/utf8.encode(chartsheet_rels_xml), - ) + archive.add_text(chartsheet_rels_part_path(sheet_id), chartsheet_rels_xml) } match workbook.vba_project { Some(value) => archive.add(vba_project_part_path, value) None => () } if include_shared { - archive.add( + archive.add_text( shared_strings_part_path, - @encoding/utf8.encode( - write_shared_strings_xml(shared_strings, total_count), + write_shared_strings_xml( + shared_strings, + total_count, + budget?=archive.next_part_budget(), ), ) } if include_calc_chain { - archive.add( + archive.add_text( calc_chain_part_path, - @encoding/utf8.encode(write_calc_chain_xml(formula_cells)), + write_calc_chain_xml(formula_cells, budget?=archive.next_part_budget()), ) } - archive.add( + archive.add_text( styles_part_path, - @encoding/utf8.encode( - write_styles_xml( - workbook.styles(), - workbook.conditional_styles(), - workbook.default_font(), - workbook.default_table_style, - workbook.default_pivot_style, - workbook.indexed_colors, - workbook.mru_colors_xml, - workbook.styles_ext_lst_xml, - ), + write_styles_xml( + workbook.styles(), + workbook.conditional_styles(), + workbook.default_font(), + workbook.default_table_style, + workbook.default_pivot_style, + workbook.indexed_colors, + workbook.mru_colors_xml, + workbook.styles_ext_lst_xml, + budget?=archive.next_part_budget(), ), ) - archive.add( + archive.add_text( theme_part_path, - @encoding/utf8.encode( - match workbook.theme_xml { - Some(xml) => xml - None => write_theme_xml(workbook.theme_colors) - }, - ), + match workbook.theme_xml { + Some(xml) => xml + None => + write_theme_xml( + workbook.theme_colors, + budget?=archive.next_part_budget(), + ) + }, ) - archive.add( + archive.add_text( core_props_part_path, - @encoding/utf8.encode(write_core_properties_xml(workbook.core_properties())), + write_core_properties_xml( + workbook.core_properties(), + budget?=archive.next_part_budget(), + ), ) - archive.add( + archive.add_text( app_props_part_path, - @encoding/utf8.encode(write_app_properties_xml(workbook.app_properties())), + write_app_properties_xml( + workbook.app_properties(), + budget?=archive.next_part_budget(), + ), ) let mut has_custom_props = false for prop in workbook.custom_properties() { @@ -3417,29 +4018,64 @@ fn write_with_io( } if has_custom_props { manifest.add_custom_properties() - archive.add( + archive.add_text( custom_props_part_path, - @encoding/utf8.encode( - write_custom_properties_xml(workbook.custom_properties()), + write_custom_properties_xml( + workbook.custom_properties(), + budget?=archive.next_part_budget(), ), ) } - archive.add( - workbook_rels_part_path, - @encoding/utf8.encode(manifest.workbook_rels_xml()), - ) - archive.add( - content_types_part_path, - @encoding/utf8.encode(manifest.content_types_xml()), - ) - match io_context.zip_writer { - Some(writer) => - writer(archive) catch { + let workbook_rels_xml = match archive.next_part_budget() { + Some(budget) => + match manifest.workbook_rels_xml_limited(budget.maximum) { + Some(xml) => xml + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + None => manifest.workbook_rels_xml() + } + archive.add_text(workbook_rels_part_path, workbook_rels_xml) + let content_types_xml = match archive.next_part_budget() { + Some(budget) => + match manifest.content_types_xml_limited(budget.maximum) { + Some(xml) => xml + None => + raise ResourceLimitExceeded( + kind=budget.resource_kind, + limit=budget.resource_limit, + actual=bounded_actual_above_limit(budget.resource_limit), + ) + } + None => manifest.content_types_xml() + } + archive.add_text(content_types_part_path, content_types_xml) + let archive = archive.finish() + match limits { + Some(policy) => + @zip.write_limited(archive, max_output_bytes=policy.max_output_bytes) catch { + OutputLimitExceeded(limit~) => + raise ResourceLimitExceeded( + kind="output_bytes", + limit~, + actual=bounded_actual_above_limit(limit), + ) err => raise UnsupportedFeature(msg=repr(err)) } None => - @zip.write(archive) catch { - err => raise UnsupportedFeature(msg=repr(err)) + match io_context.zip_writer { + Some(writer) => + writer(archive) catch { + err => raise UnsupportedFeature(msg=repr(err)) + } + None => + @zip.write(archive) catch { + err => raise UnsupportedFeature(msg=repr(err)) + } } } } @@ -3449,6 +4085,19 @@ pub fn write(workbook : Workbook) -> Bytes raise XlsxError { write_with_io(workbook, workbook_io_context(workbook)) } +///| +/// Serializes a workbook under a fail-closed construction and output policy. +/// Generated parts are bounded before archive retention, then the ZIP writer +/// proves the final package size before allocating its output buffer. A custom +/// zip writer installed on the workbook is intentionally bypassed so it cannot +/// weaken this policy. +pub fn write_limited( + workbook : Workbook, + limits : WriteLimits, +) -> Bytes raise XlsxError { + write_with_io(workbook, workbook_io_context(workbook), limits~) +} + ///| pub fn write_with_password( workbook : Workbook, diff --git a/xlsx/write_comments_vml.mbt b/xlsx/write_comments_vml.mbt index f4ad7fcb..cf08d80f 100644 --- a/xlsx/write_comments_vml.mbt +++ b/xlsx/write_comments_vml.mbt @@ -16,8 +16,14 @@ fn truncate_comment_content(comment : Comment) -> (String, Array[RichTextRun]) { } ///| -fn write_comments_xml(comments : ArrayView[Comment]) -> String { - fn write_rich_text_color(sb : StringBuilder, font : RichTextFont) -> Unit { +fn write_comments_xml( + comments : ArrayView[Comment], + budget? : WritePartBudget, +) -> String raise XlsxError { + fn write_rich_text_color( + sb : LimitedXmlBuilder, + font : RichTextFont, + ) -> Unit raise XlsxError { let tint = match font.color_tint { Some(v) => if v == 0.0 { None } else { Some(v) } None => None @@ -54,7 +60,9 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { } else { normalized } - sb.write_view(" sb.write_view(" tint=\"\{v}\"") None => () @@ -83,7 +91,7 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { authors.push(author) } } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -91,7 +99,7 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { sb.write_view(" \n") for author in authors { sb.write_view(" ") - sb.write_view(escape_xml_text(author)) + sb.write_xml_text(author) sb.write_view("\n") } sb.write_view(" \n") @@ -110,7 +118,7 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { let author_id = author_ids.get(author).unwrap_or(0) let (text_value, runs) = truncate_comment_content(comment) sb.write_view(" \n") sb.write_view(" ") if text_value != "" { @@ -121,7 +129,7 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { } else { sb.write_view("") } - sb.write_view(escape_xml_text(text_value)) + sb.write_xml_text(text_value) sb.write_view("") } for run in runs { @@ -151,8 +159,11 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { sb.write_view("") } match font.underline { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } match font.size { @@ -168,8 +179,11 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { None => () } match font.family { - Some(family) => - sb.write_view("") + Some(family) => { + sb.write_view("") + } None => () } match font.color { @@ -182,13 +196,19 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { } } match font.vert_align { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } match font.scheme { - Some(value) => - sb.write_view("") + Some(value) => { + sb.write_view("") + } None => () } sb.write_view("") @@ -202,7 +222,7 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { } else { sb.write_view("") } - sb.write_view(escape_xml_text(run.text)) + sb.write_xml_text(run.text) sb.write_view("") } sb.write_view("\n") @@ -217,8 +237,9 @@ fn write_comments_xml(comments : ArrayView[Comment]) -> String { fn write_comments_vml( sheet_id : Int, comments : ArrayView[Comment], + budget? : WritePartBudget, ) -> String raise XlsxError { - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -263,36 +284,34 @@ fn write_comments_vml( } ///| -fn extract_vml_shapetype(xml : StringView) -> String? { - let xml_str = xml.to_owned() - let start = match xml_str.find(" StringView? { + let start = match xml.find(" pos None => return None } - let end = match xml_str.find("") { - Some(pos) => pos + "".length() + let tail = xml[start:] + let end = match tail.find("") { + Some(pos) => start + pos + "".length() None => return None } - let slice = xml_str.unsafe_substring(start~, end~) - Some(slice.to_string()) + Some(xml[start:end]) } ///| -fn extract_vml_shapes(xml : StringView) -> Array[String] { - let shapes : Array[String] = [] - let xml_str = xml.to_owned() +fn extract_vml_shapes(xml : StringView) -> Array[StringView] { + let shapes : Array[StringView] = [] let needle = " pos None => break } let start = start_pos + rel let after = start + needle.length() - let next_char = if after < xml_str.length() { - xml_str.get_char(after) + let next_char = if after < xml.length() { + xml.get_char(after) } else { None } @@ -304,42 +323,40 @@ fn extract_vml_shapes(xml : StringView) -> Array[String] { start_pos = start + 1 continue } - let shape_rest = xml_str.unsafe_substring(start~, end=xml_str.length()) + let shape_rest = xml[start:] let end_rel = match shape_rest.find("") { Some(pos) => pos None => break } let end = start + end_rel + "".length() - let slice = xml_str.unsafe_substring(start~, end~) - shapes.push(slice.to_string()) + shapes.push(xml[start:end]) start_pos = end } shapes } ///| -fn vml_shape_id(shape_xml : StringView) -> String? { - let xml_str = shape_xml.to_owned() +fn vml_shape_id(shape_xml : StringView) -> StringView? { let id_key = "id=\"" - let start = match xml_str.find(id_key) { + let start = match shape_xml.find(id_key) { Some(pos) => pos + id_key.length() None => return None } - let rest = xml_str.unsafe_substring(start~, end=xml_str.length()) + let rest = shape_xml[start:] let end_rel = match rest.find("\"") { Some(pos) => pos None => return None } let end = start + end_rel - let slice = xml_str.unsafe_substring(start~, end~) - Some(slice.to_string()) + Some(shape_xml[start:end]) } ///| fn filter_vml_shapes_by_id( base_xml : String, remove_ids : Array[String], -) -> String { + budget? : WritePartBudget, +) -> String raise XlsxError { if remove_ids.length() == 0 { return base_xml } @@ -351,11 +368,11 @@ fn filter_vml_shapes_by_id( remove_map[id] = true } let shapes = extract_vml_shapes(base_xml) - let kept : Array[String] = [] + let kept : Array[StringView] = [] for shape in shapes { match vml_shape_id(shape) { Some(id) => - if remove_map.contains(id) { + if remove_map.contains(id.to_owned()) { continue } else { kept.push(shape) @@ -367,7 +384,7 @@ fn filter_vml_shapes_by_id( let end = base_xml.find("").unwrap_or(base_xml.length()) let prefix = base_xml.unsafe_substring(start=0, end=insert_at) let suffix = base_xml.unsafe_substring(start=end, end=base_xml.length()) - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(prefix) for shape in kept { sb.write_view(shape) @@ -377,44 +394,42 @@ fn filter_vml_shapes_by_id( } ///| -fn merge_vml_drawings(base_xml : String, comment_xml : String) -> String { +fn merge_vml_drawings( + base_xml : String, + comment_xml : String, + budget? : WritePartBudget, +) -> String raise XlsxError { if !base_xml.contains("") { return comment_xml } - let mut merged = base_xml - if !merged.contains("_x0000_t202") { + let shape_type = if !base_xml.contains("_x0000_t202") { match extract_vml_shapetype(comment_xml) { - Some(shape_type) => { - let insert_at = merged - .find("").unwrap_or(merged.length())) - let prefix = merged.unsafe_substring(start=0, end=insert_at) - let suffix = merged.unsafe_substring( - start=insert_at, - end=merged.length(), - ) - let sb = StringBuilder::new() - sb.write_view(prefix) - sb.write_view(shape_type) - sb.write_view(suffix) - merged = sb.to_string() - } - None => () + Some(value) => Some(value) + None => None } + } else { + None } let shapes = extract_vml_shapes(comment_xml) - if shapes.length() == 0 { - return merged + if shape_type is None && shapes.length() == 0 { + return base_xml } - let insert_at = merged.find("").unwrap_or(merged.length()) - let prefix = merged.unsafe_substring(start=0, end=insert_at) - let suffix = merged.unsafe_substring(start=insert_at, end=merged.length()) - let sb = StringBuilder::new() - sb.write_view(prefix) + let end = base_xml.find("").unwrap_or(base_xml.length()) + let shape_type_at = match shape_type { + Some(_) => base_xml.find(" end + } + let sb = LimitedXmlBuilder::new(budget) + sb.write_view(base_xml[:shape_type_at]) + match shape_type { + Some(value) => sb.write_view(value) + None => () + } + sb.write_view(base_xml[shape_type_at:end]) for shape in shapes { sb.write_view(shape) } - sb.write_view(suffix) + sb.write_view(base_xml[end:]) sb.to_string() } @@ -423,45 +438,40 @@ fn merge_vml_drawings_with_shapetype( base_xml : String, extra_xml : String, shape_type_id : StringView, -) -> String { + budget? : WritePartBudget, +) -> String raise XlsxError { if !base_xml.contains("") { return extra_xml } - let mut merged = base_xml let shape_token = "id=\"\{shape_type_id.to_owned()}\"" - if !merged.contains(shape_token) { + let shape_type = if !base_xml.contains(shape_token) { match extract_vml_shapetype(extra_xml) { - Some(shape_type) => { - let insert_at = merged - .find("").unwrap_or(merged.length())) - let prefix = merged.unsafe_substring(start=0, end=insert_at) - let suffix = merged.unsafe_substring( - start=insert_at, - end=merged.length(), - ) - let sb = StringBuilder::new() - sb.write_view(prefix) - sb.write_view(shape_type) - sb.write_view(suffix) - merged = sb.to_string() - } - None => () + Some(value) => Some(value) + None => None } + } else { + None } let shapes = extract_vml_shapes(extra_xml) - if shapes.length() == 0 { - return merged + if shape_type is None && shapes.length() == 0 { + return base_xml } - let insert_at = merged.find("").unwrap_or(merged.length()) - let prefix = merged.unsafe_substring(start=0, end=insert_at) - let suffix = merged.unsafe_substring(start=insert_at, end=merged.length()) - let sb = StringBuilder::new() - sb.write_view(prefix) + let end = base_xml.find("").unwrap_or(base_xml.length()) + let shape_type_at = match shape_type { + Some(_) => base_xml.find(" end + } + let sb = LimitedXmlBuilder::new(budget) + sb.write_view(base_xml[:shape_type_at]) + match shape_type { + Some(value) => sb.write_view(value) + None => () + } + sb.write_view(base_xml[shape_type_at:end]) for shape in shapes { sb.write_view(shape) } - sb.write_view(suffix) + sb.write_view(base_xml[end:]) sb.to_string() } @@ -469,8 +479,9 @@ fn merge_vml_drawings_with_shapetype( fn write_header_footer_vml( sheet_id : Int, images : ArrayView[HeaderFooterImage], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view("\n") sb.write_view( "\n", @@ -511,11 +522,11 @@ fn write_header_footer_vml( ) let style = "position:absolute;margin-left:0;margin-top:0;width:\{image.width};height:\{image.height};z-index:1" sb.write_view(" \n") sb.write_view(" \n") sb.write_view(" \n") @@ -714,7 +725,10 @@ test "write comments vml wb: vml writer and extraction branches" { let shapes = extract_vml_shapes(vml) inspect(shapes.length(), content="1") - debug_inspect(vml_shape_id(shapes[0]), content="Some(\"_x0000_s1025\")") + match vml_shape_id(shapes[0]) { + Some(id) => inspect(id.to_owned(), content="_x0000_s1025") + None => fail("expected VML shape id") + } inspect(extract_vml_shapes("").length(), content="0") debug_inspect(vml_shape_id(""), content="None") diff --git a/xlsx/write_limits_test.mbt b/xlsx/write_limits_test.mbt new file mode 100644 index 00000000..c82290f5 --- /dev/null +++ b/xlsx/write_limits_test.mbt @@ -0,0 +1,194 @@ +///| +fn write_limit_fixture() -> @xlsx.Workbook raise { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Data")) + workbook.set_cell("Data", "A1", "bounded write") + workbook.set_cell_formula("Data", "B1", "=LEN(A1)") + workbook +} + +///| +fn archive_uncompressed_stats(bytes : BytesView) -> (Int, Int, Int) raise { + let archive = @zip.read(bytes) + let mut largest = 0 + let mut total = 0 + for entry in archive.entries() { + let size = entry.data().length() + if size > largest { + largest = size + } + total = total + size + } + (archive.entries().length(), largest, total) +} + +///| +test "bounded writes reject aggregate archive growth before ZIP output" { + let workbook = write_limit_fixture() + let ordinary = @xlsx.write(workbook) + let (entry_count, largest, total) = archive_uncompressed_stats(ordinary) + assert_true(total > largest) + let result : Result[Bytes, Error] = Ok( + @xlsx.write_limited( + workbook, + @xlsx.WriteLimits::with_values( + max_output_bytes=ordinary.length() + 1024, + max_archive_entries=entry_count, + max_entry_uncompressed_bytes=largest, + max_total_uncompressed_bytes=total - 1, + ), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="total_uncompressed_bytes") + assert_eq(limit, total - 1) + assert_true(actual > limit) + } + _ => fail("expected aggregate bounded-write rejection") + } +} + +///| +test "bounded writes size ZIP output before allocating an oversized package" { + let workbook = write_limit_fixture() + let ordinary = @xlsx.write(workbook) + let (entry_count, largest, total) = archive_uncompressed_stats(ordinary) + let result : Result[Bytes, Error] = Ok( + @xlsx.write_limited( + workbook, + @xlsx.WriteLimits::with_values( + max_output_bytes=ordinary.length() - 1, + max_archive_entries=entry_count, + max_entry_uncompressed_bytes=largest, + max_total_uncompressed_bytes=total, + ), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="output_bytes") + assert_eq(limit, ordinary.length() - 1) + assert_true(actual > limit) + } + _ => fail("expected bounded ZIP output rejection") + } +} + +///| +test "bounded writes stream escaped style attributes into the part budget" { + let baseline = write_limit_fixture() + let baseline_bytes = @xlsx.write(baseline) + let (_, largest, total) = archive_uncompressed_stats(baseline_bytes) + let workbook = write_limit_fixture() + let style_id = workbook.add_style( + @xlsx.Style::number_format("&".repeat(largest)), + ) + workbook.set_cell_style("Data", "A1", style_id) + let result : Result[Bytes, Error] = Ok( + @xlsx.write_limited( + workbook, + @xlsx.WriteLimits::with_values( + max_output_bytes=baseline_bytes.length() * 16, + max_archive_entries=64, + max_entry_uncompressed_bytes=largest + 1024, + max_total_uncompressed_bytes=total * 16, + ), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="entry_uncompressed_bytes") + assert_eq(limit, largest + 1024) + assert_true(actual > limit) + } + _ => fail("expected escaped style XML to honor the entry budget") + } +} + +///| +test "bounded writes preflight retained raw chart XML before UTF-8 encoding" { + let baseline = write_limit_fixture() + let baseline_bytes = @xlsx.write(baseline) + let (_, largest, total) = archive_uncompressed_stats(baseline_bytes) + let workbook = write_limit_fixture() + workbook.add_chart( + "Data", + "C3", + "" + "x".repeat(largest + 1024) + "", + ) + let result : Result[Bytes, Error] = Ok( + @xlsx.write_limited( + workbook, + @xlsx.WriteLimits::with_values( + max_output_bytes=baseline_bytes.length() * 16, + max_archive_entries=64, + max_entry_uncompressed_bytes=largest + 512, + max_total_uncompressed_bytes=total * 16, + ), + ), + ) catch { + error => Err(error) + } + match result { + Err(@xlsx.ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="entry_uncompressed_bytes") + assert_eq(limit, largest + 512) + assert_true(actual > limit) + } + _ => fail("expected raw chart XML to honor the entry budget") + } +} + +///| +test "writes reject XML-forbidden cell text before package publication" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Data")) + workbook.set_cell("Data", "A1", "before\u{1}after") + let result : Result[Bytes, Error] = Ok(@xlsx.write(workbook)) catch { + error => Err(error) + } + match result { + Err(@xlsx.InvalidXml(msg~)) => assert_true(msg.contains("code point 1")) + _ => fail("expected forbidden XML character rejection") + } +} + +///| +test "bounded writes parent-budget and immediately archive composed VML" { + let workbook = @xlsx.Workbook::new() + ignore(workbook.add_sheet("Data")) + workbook.add_comment("Data", { + cell: "A1", + author: "Reviewer", + author_id: None, + text: "bounded comment VML", + paragraph: [], + width: None, + height: None, + }) + workbook.add_form_control( + "Data", + @xlsx.FormControl::new("B2", "Button", text="bounded control VML"), + ) + let ordinary = @xlsx.write(workbook) + let (entry_count, largest, total) = archive_uncompressed_stats(ordinary) + let bounded = @xlsx.write_limited( + workbook, + @xlsx.WriteLimits::with_values( + max_output_bytes=ordinary.length(), + max_archive_entries=entry_count, + max_entry_uncompressed_bytes=largest, + max_total_uncompressed_bytes=total, + ), + ) + let archive = @zip.read(bounded) + assert_true(archive.get("xl/drawings/vmlDrawing1.vml") is Some(_)) + assert_eq(archive.entries().length(), entry_count) +} diff --git a/xlsx/write_limits_wbtest.mbt b/xlsx/write_limits_wbtest.mbt new file mode 100644 index 00000000..2d880f88 --- /dev/null +++ b/xlsx/write_limits_wbtest.mbt @@ -0,0 +1,52 @@ +///| +test "composition budget debits retained intermediates cumulatively" { + let limits = WriteLimits::with_values( + max_output_bytes=64, + max_archive_entries=8, + max_entry_uncompressed_bytes=8, + max_total_uncompressed_bytes=12, + ) + let tracker : WriteCompositionBudgetTracker = { + limits: Some(limits), + archived_bytes: 4, + retained_bytes: 0, + } + tracker.consume("12345") + let result : Result[Unit, Error] = Ok(tracker.consume("1234")) catch { + error => Err(error) + } + match result { + Err(ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="total_uncompressed_bytes") + assert_eq(limit, 12) + assert_true(actual > limit) + } + _ => fail("expected cumulative composition-budget rejection") + } +} + +///| +test "composition budget retains the individual entry ceiling" { + let limits = WriteLimits::with_values( + max_output_bytes=64, + max_archive_entries=8, + max_entry_uncompressed_bytes=8, + max_total_uncompressed_bytes=32, + ) + let tracker : WriteCompositionBudgetTracker = { + limits: Some(limits), + archived_bytes: 0, + retained_bytes: 0, + } + let result : Result[Unit, Error] = Ok(tracker.consume("123456789")) catch { + error => Err(error) + } + match result { + Err(ResourceLimitExceeded(kind~, limit~, actual~)) => { + inspect(kind, content="entry_uncompressed_bytes") + assert_eq(limit, 8) + assert_true(actual > limit) + } + _ => fail("expected per-entry composition-budget rejection") + } +} diff --git a/xlsx/write_rels_xml.mbt b/xlsx/write_rels_xml.mbt index cf529f8f..63129e59 100644 --- a/xlsx/write_rels_xml.mbt +++ b/xlsx/write_rels_xml.mbt @@ -1,6 +1,6 @@ ///| /// Emit the shared `` + opening `` preamble. -fn write_rels_open(sb : StringBuilder) -> Unit { +fn write_rels_open(sb : LimitedXmlBuilder) -> Unit raise XlsxError { sb.write_view("\n") sb.write_view( "\n", @@ -12,18 +12,18 @@ fn write_rels_open(sb : StringBuilder) -> Unit { /// `external` adds `TargetMode="External"`. Centralizing the element format /// keeps every rels writer byte-identical. fn write_relationship_with_id( - sb : StringBuilder, + sb : LimitedXmlBuilder, rel_id : StringView, rel_type : StringView, target : StringView, external? : Bool = false, -) -> Unit { +) -> Unit raise XlsxError { sb.write_view(" \n") } else { @@ -33,12 +33,12 @@ fn write_relationship_with_id( ///| fn write_relationship( - sb : StringBuilder, + sb : LimitedXmlBuilder, rel_id : Int, rel_type : String, target : String, external? : Bool = false, -) -> Unit { +) -> Unit raise XlsxError { write_relationship_with_id(sb, "rId\{rel_id}", rel_type, target, external~) } @@ -52,8 +52,9 @@ fn write_sheet_rels_xml( comment_targets : ArrayView[String], vml_targets : ArrayView[String], image_targets : ArrayView[String], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) let mut rel_id = 1 match drawing_target { @@ -96,8 +97,11 @@ fn write_sheet_rels_xml( } ///| -fn write_pivot_table_rels_xml(cache_id : Int) -> String { - let sb = StringBuilder::new() +fn write_pivot_table_rels_xml( + cache_id : Int, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) write_relationship( sb, @@ -110,8 +114,11 @@ fn write_pivot_table_rels_xml(cache_id : Int) -> String { } ///| -fn write_pivot_cache_rels_xml(cache_id : Int) -> String { - let sb = StringBuilder::new() +fn write_pivot_cache_rels_xml( + cache_id : Int, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) write_relationship( sb, @@ -129,8 +136,9 @@ fn write_drawing_rels_xml( chart_targets : Array[(Int, String)], hyperlink_targets : Array[DrawingHyperlink], preserved_relationships? : ArrayView[PreservedDrawingRelationship] = [], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) for entry in image_targets { let (rel_id, target) = entry @@ -165,8 +173,9 @@ fn write_drawing_rels_xml( ///| fn write_preserved_drawing_rels_xml( relationships : ArrayView[PreservedDrawingRelationship], -) -> String { - let sb = StringBuilder::new() + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) for relationship in relationships { write_relationship_with_id( @@ -182,8 +191,11 @@ fn write_preserved_drawing_rels_xml( } ///| -fn write_vml_rels_xml(image_targets : Array[String]) -> String { - let sb = StringBuilder::new() +fn write_vml_rels_xml( + image_targets : Array[String], + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) write_rels_open(sb) let mut rel_id = 1 for target in image_targets { diff --git a/xlsx/write_workbook_xml.mbt b/xlsx/write_workbook_xml.mbt index 7a3c98b7..73888978 100644 --- a/xlsx/write_workbook_xml.mbt +++ b/xlsx/write_workbook_xml.mbt @@ -5,19 +5,21 @@ fn write_workbook_xml( pivot_cache_rel_entries : ArrayView[(Int, String)], slicer_cache_rel_ids_x14 : ArrayView[String], slicer_cache_rel_ids_x15 : ArrayView[String], -) -> String { + budget? : WritePartBudget, +) -> String raise XlsxError { fn write_pivot_caches_xml( pivot_cache_rel_entries : ArrayView[(Int, String)], - ) -> String? { + budget : WritePartBudget?, + ) -> String? raise XlsxError { if pivot_cache_rel_entries.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") for entry in pivot_cache_rel_entries { let (cache_id, rel_id) = entry sb.write_view(" \n") } sb.write_view(" \n") @@ -27,12 +29,13 @@ fn write_workbook_xml( fn write_workbook_ext_lst_xml( slicer_cache_rel_ids_x14 : ArrayView[String], slicer_cache_rel_ids_x15 : ArrayView[String], - ) -> String? { + budget : WritePartBudget?, + ) -> String? raise XlsxError { if slicer_cache_rel_ids_x14.length() == 0 && slicer_cache_rel_ids_x15.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" \n") if slicer_cache_rel_ids_x14.length() > 0 { sb.write_view( @@ -43,7 +46,7 @@ fn write_workbook_xml( ) for id in slicer_cache_rel_ids_x14 { sb.write_view(" \n") } sb.write_view(" \n") @@ -58,7 +61,7 @@ fn write_workbook_xml( ) for id in slicer_cache_rel_ids_x15 { sb.write_view(" \n") } sb.write_view(" \n") @@ -70,7 +73,7 @@ fn write_workbook_xml( let sheets = workbook.sheet_order let worksheet_count = workbook.sheets().length() - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) let sheet_index : Map[String, Int] = Map([]) for i, entry in sheets { sheet_index.set(normalize_sheet_name(workbook.sheet_entry_name(entry)), i) @@ -79,7 +82,11 @@ fn write_workbook_xml( sb.write_view( "\n", ) - match write_workbook_pr_xml(workbook.workbook_props()) { + match + write_workbook_pr_xml( + workbook.workbook_props(), + budget?=sb.remaining_budget(), + ) { Some(value) => sb.write_view(value) None => () } @@ -105,7 +112,7 @@ fn write_workbook_xml( ChartSheet(idx) => chartsheet_rel_start + idx } sb.write_view(" ") - sb.write_view(escape_xml_text(defined.refers_to)) + sb.write_xml_text(defined.refers_to) sb.write_view("\n") } if wrote { @@ -152,20 +159,26 @@ fn write_workbook_xml( } } match workbook.workbook_protection { - Some(value) => sb.write_view(write_workbook_protection_xml(value)) + Some(value) => + sb.write_view( + write_workbook_protection_xml(value, budget?=sb.remaining_budget()), + ) None => () } - match write_calc_pr_xml(workbook.calc_props()) { + match + write_calc_pr_xml(workbook.calc_props(), budget?=sb.remaining_budget()) { Some(value) => sb.write_view(value) None => () } - match write_pivot_caches_xml(pivot_cache_rel_entries) { + match write_pivot_caches_xml(pivot_cache_rel_entries, sb.remaining_budget()) { Some(value) => sb.write_view(value) None => () } match write_workbook_ext_lst_xml( - slicer_cache_rel_ids_x14, slicer_cache_rel_ids_x15, + slicer_cache_rel_ids_x14, + slicer_cache_rel_ids_x15, + sb.remaining_budget(), ) { Some(xml) => sb.write_view(xml) None => () @@ -175,11 +188,14 @@ fn write_workbook_xml( } ///| -fn write_workbook_pr_xml(options : WorkbookPropsOptions) -> String? { +fn write_workbook_pr_xml( + options : WorkbookPropsOptions, + budget? : WritePartBudget, +) -> String? raise XlsxError { if workbook_props_is_empty(options) { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" sb.write_view(" date1904=\"\{bool_attr(value)}\"") @@ -192,7 +208,7 @@ fn write_workbook_pr_xml(options : WorkbookPropsOptions) -> String? { match options.code_name { Some(value) => { sb.write_view(" codeName=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -202,11 +218,14 @@ fn write_workbook_pr_xml(options : WorkbookPropsOptions) -> String? { } ///| -fn write_calc_pr_xml(options : CalcPropsOptions) -> String? { +fn write_calc_pr_xml( + options : CalcPropsOptions, + budget? : WritePartBudget, +) -> String? raise XlsxError { if calc_props_is_empty(options) { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" sb.write_view(" calcId=\"\{value}\"") diff --git a/xlsx/write_worksheet_layout_xml.mbt b/xlsx/write_worksheet_layout_xml.mbt index 227a69d8..9d53698a 100644 --- a/xlsx/write_worksheet_layout_xml.mbt +++ b/xlsx/write_worksheet_layout_xml.mbt @@ -1,5 +1,8 @@ ///| -fn write_row_attributes(sb : StringBuilder, dim : RowDimension) -> Unit { +fn write_row_attributes( + sb : LimitedXmlBuilder, + dim : RowDimension, +) -> Unit raise XlsxError { match dim.height { Some(height) => { sb.write_view(" ht=\"") @@ -22,10 +25,10 @@ fn write_row_attributes(sb : StringBuilder, dim : RowDimension) -> Unit { ///| fn write_col_attributes( - sb : StringBuilder, + sb : LimitedXmlBuilder, col : Int, dim : ColDimension, -) -> Unit { +) -> Unit raise XlsxError { sb.write_view(" { @@ -57,13 +60,16 @@ fn page_margin_value(value : Double?, fallback : Double) -> Double { } ///| -fn write_print_options_xml(options : PageLayoutMarginsOptions) -> String? { +fn write_print_options_xml( + options : PageLayoutMarginsOptions, + budget? : WritePartBudget, +) -> String? raise XlsxError { let has_horizontal = options.horizontally is Some(_) let has_vertical = options.vertically is Some(_) if !has_horizontal && !has_vertical { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" sb.write_view(" horizontalCentered=\"1\"") @@ -80,8 +86,11 @@ fn write_print_options_xml(options : PageLayoutMarginsOptions) -> String? { } ///| -fn write_page_margins_xml(options : PageLayoutMarginsOptions) -> String { - let sb = StringBuilder::new() +fn write_page_margins_xml( + options : PageLayoutMarginsOptions, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" String { } ///| -fn write_page_setup_xml(layout : PageLayoutOptions) -> String { - let sb = StringBuilder::new() +fn write_page_setup_xml( + layout : PageLayoutOptions, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" sb.write_view(" blackAndWhite=\"1\"") @@ -123,7 +135,7 @@ fn write_page_setup_xml(layout : PageLayoutOptions) -> String { match layout.orientation { Some(value) => { sb.write_view(" orientation=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -131,7 +143,7 @@ fn write_page_setup_xml(layout : PageLayoutOptions) -> String { match layout.page_order { Some(value) => { sb.write_view(" pageOrder=\"") - sb.write_view(escape_xml_attr(value)) + sb.write_xml_attr(value) sb.write_view("\"") } None => () @@ -152,8 +164,11 @@ fn write_page_setup_xml(layout : PageLayoutOptions) -> String { } ///| -fn write_header_footer_xml(options : HeaderFooterOptions) -> String { - let sb = StringBuilder::new() +fn write_header_footer_xml( + options : HeaderFooterOptions, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" String { sb.write_view(">\n") if options.odd_header != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.odd_header)) + sb.write_xml_text(options.odd_header) sb.write_view("\n") } if options.odd_footer != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.odd_footer)) + sb.write_xml_text(options.odd_footer) sb.write_view("\n") } if options.even_header != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.even_header)) + sb.write_xml_text(options.even_header) sb.write_view("\n") } if options.even_footer != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.even_footer)) + sb.write_xml_text(options.even_footer) sb.write_view("\n") } if options.first_header != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.first_header)) + sb.write_xml_text(options.first_header) sb.write_view("\n") } if options.first_footer != "" { sb.write_view(" ") - sb.write_view(escape_xml_text(options.first_footer)) + sb.write_xml_text(options.first_footer) sb.write_view("\n") } sb.write_view(" \n") @@ -211,27 +226,30 @@ fn write_header_footer_xml(options : HeaderFooterOptions) -> String { } ///| -fn write_sheet_protection_xml(protection : SheetProtection) -> String { - let sb = StringBuilder::new() +fn write_sheet_protection_xml( + protection : SheetProtection, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) sb.write_view(" 0 { @@ -293,11 +311,12 @@ fn write_sheet_protection_xml(protection : SheetProtection) -> String { fn write_page_breaks_xml( tag_name : StringView, breaks : ArrayView[PageBreak], -) -> String? { + budget? : WritePartBudget, +) -> String? raise XlsxError { if breaks.length() == 0 { return None } - let sb = StringBuilder::new() + let sb = LimitedXmlBuilder::new(budget) let mut manual_count = 0 for brk in breaks { if brk.manual { @@ -325,20 +344,23 @@ fn write_page_breaks_xml( } ///| -fn write_auto_filter_xml(filter : AutoFilter) -> String raise XlsxError { - let sb = StringBuilder::new() +fn write_auto_filter_xml( + filter : AutoFilter, + budget? : WritePartBudget, +) -> String raise XlsxError { + let sb = LimitedXmlBuilder::new(budget) let (min_row, min_col, max_row, max_col) = parse_range_ref(filter.range_ref) let start_ref = cell_ref_from(min_row, min_col) let end_ref = cell_ref_from(max_row, max_col) let range_ref = "\{start_ref}:\{end_ref}" if filter.columns.length() == 0 { sb.write_view(" \n") return sb.to_string() } sb.write_view(" \n") for column in filter.columns { if column.col < min_col || column.col > max_col { @@ -351,7 +373,7 @@ fn write_auto_filter_xml(filter : AutoFilter) -> String raise XlsxError { sb.write_view(" \n") for value in values { sb.write_view(" \n") } sb.write_view(" \n") @@ -367,9 +389,9 @@ fn write_auto_filter_xml(filter : AutoFilter) -> String raise XlsxError { sb.write_view(">\n") for entry in custom.filters { sb.write_view(" \n") } sb.write_view(" \n")