From 8da61f8060f853dd45bf6dd14975b00e0202a24f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 05:26:36 +0000 Subject: [PATCH 01/71] Spec: safe VBA/StarBasic macro scripting (M001-M012), draft for approval Defines the security-first design for macro support: disabled-by-default trust model keyed on a local trust store, capability-gated execution through a pure tree-walking interpreter (no JIT, iOS-compatible), a permanent 'never' list of refused features (process exec, FFI, COM/UNO, path-addressed file I/O, p-code, XLM), payload preservation in the provenance layer (fixing today's silent macro stripping on save), and an 8-phase implementation plan. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB --- docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md | 634 ++++++++++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md diff --git a/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md new file mode 100644 index 00000000..247f6140 --- /dev/null +++ b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md @@ -0,0 +1,634 @@ + + +# AppThere Loki — Safe Macro Scripting Spec (VBA & StarBasic) + +**Status:** Draft — awaiting approval (v0.1, 2026-07-16) +**Series:** AppThere Client, ADRs M001–M012 +**Companions:** ADR-0002 (version-preserving round-trip), ADR-0009 (target +layering), `LOKI_HEADLESS_SERVER_SPEC.md` (C021–C028 — headless policy §10) +**Target edition:** Rust 2024 + +--- + +## 0. Scope + +Real-world documents contain macros: VBA projects in OOXML macro-enabled +formats (`.docm`, `.xlsm`, `.pptm`, `.dotm`, `.xltm`) and StarBasic/Basic +script libraries in ODF packages (`Basic/`, `Scripts/`, +`` event bindings). This spec defines how Loki supports +those documents **without becoming a malware vector** — macros are the +single most abused office-document feature in the wild, and every design +decision below starts from that fact. + +### Prime directive + +> **Security beats compatibility.** Where a VBA/StarBasic feature cannot be +> implemented safely, we break compatibility — deliberately, visibly, and +> permanently. Loki does not aim to run every real-world macro; it aims to +> run the *benign* majority (formatting helpers, data entry, UDFs, mail-merge +> style automation) while making the malicious minority *structurally +> impossible*, not merely prompted-away. + +### Goals + +1. **Stop destroying macros** (today Loki silently strips them on save — + see §3). Preserve macro payloads byte-for-byte across load→edit→save. +2. Execute a curated, capability-gated subset of VBA and StarBasic in a + pure-Rust, `forbid(unsafe_code)`, tree-walking **interpreter** (no JIT — + iOS-compatible by construction). +3. **Disabled by default** for any document the user did not author, with + an explicit, per-document, revocable trust grant. +4. **Capability permissions** for anything beyond reading the open + document: document writes, dialogs, clipboard, file access, printing — + each individually granted, deniable, and auditable. +5. A **"never" list** (§7) of features that are refused permanently + regardless of grants: process spawning, FFI, COM/ActiveX, registry, + p-code execution, Excel 4.0 macro sheets, and others. + +### Non-goals + +- **Bug-for-bug Office/LibreOffice compatibility.** We implement the + documented language (MS-VBAL; OOo Basic grammar), not quirks. +- **UserForms / MS-OFORMS rendering** (v1). Deferred, not refused — see §14. +- **A general UNO bridge** for StarBasic. Refused; a small compat shim maps + the most common idioms onto our object model (§6.4). +- **Macro translation across formats.** Converting `.docm` → `.odt` does not + transpile VBA to StarBasic (or vice versa); payloads are dropped with a + warning (§3.5). +- **Server-side execution.** Macros never run in `loki-server` or + `loki-headless`, ever (§10). + +### Engineering standards + +Inherited from the workspace conventions: 300-line file ceiling, +`#![forbid(unsafe_code)]` in every new crate, `thiserror` typed errors, no +`unwrap()`/`expect()` in library code, SPDX line 1, `fl!()` for all +user-visible strings, audit-first / implement-second. + +--- + +## 1. Threat model + +What macro malware actually does, and which layer of this design stops it: + +| # | Attack class | Real-world example | Stopped by | +|---|---|---|---| +| T1 | **Auto-execution on open** | `AutoOpen`/`Document_Open`/`Workbook_Open` droppers; ODF `office:scripts` `OnLoad` event listeners | §2: macros off by default; §5.6: on-open events need a *separate* grant even in trusted docs | +| T2 | **Payload download + execution** | `XMLHTTP` fetch → `Shell`/`CreateObject("WScript.Shell")` | §7: no process spawning, no COM, ever; network is deny-by-default and v2-deferred | +| T3 | **Filesystem ransomware / droppers** | `FileSystemObject`, `Open ... For Output`, `Kill` | §5: file I/O only through OS picker-mediated handles; no path-addressed ambient FS API | +| T4 | **Data exfiltration** | read doc/clipboard → POST to attacker host | §5: clipboard + network are separate capabilities; network deferred to v2 with per-host prompts | +| T5 | **VBA stomping / p-code abuse** | source stream wiped, malicious compiled p-code executes | §4.4: Loki *only* parses decompressed source; p-code and `PerformanceCache` are never read, never executed | +| T6 | **Excel 4.0 (XLM) macro sheets** | `=EXEC()` in hidden macro sheets | §7: never implemented; sheets preserved as inert data, flagged in UI | +| T7 | **Dialog spoofing / social engineering** | fake "security update" MsgBox chains | §5.5: macro-originated dialogs are rate-limited and rendered in a visually distinct, badged frame that app chrome never uses | +| T8 | **Resource exhaustion (DoS)** | infinite loops, gigabyte string concat | §8: fuel metering, memory caps, watchdog + always-available cancel | +| T9 | **Parser exploitation** | malformed CFB/OVBA/XML crafted to exploit the *reader* | §12: parsing is `forbid(unsafe_code)` pure Rust, fuzzed in CI, and runs before any trust decision — so it must be hardened regardless | +| T10 | **Trust-metadata forgery** | document claims "I am trusted" in its own bytes | §2.4: trust state lives *only* in the local user profile, keyed by payload hash; nothing inside the file can influence trust | +| T11 | **Remote/template macro injection** | `.docx` pointing at remote `.dotm` with macros | §7: attached/remote templates are never fetched; template macros only run from a file the user explicitly opened | +| T12 | **Cross-document worming** | macro copies itself into other open docs / Normal.dotm | §6: the object model exposes *only the host document*; no `Documents` collection write-access, no template store, no `VBProject` self-modification API | + +Residual risk we accept and document: a user can explicitly trust a +malicious document and grant it document-write access, damaging *that +document* (undo + on-disk original mitigate) — the grants UI is designed to +make the blast radius legible before consent. + +--- + +## 2. M001 — Trust model: authored-by-me, else disabled + +### 2.1 Default state + +A document containing a macro payload opens with macros **disabled** — +parsed for display purposes at most, never executed. Opening is never +blocked; there is no modal prompt on open (prompt fatigue trains users to +click "Enable"). Instead a passive, non-modal infobar states that macros +are present and disabled (§9.1). + +### 2.2 What "the user authored" means + +Trust is *never* inferred from the file's own content or metadata (T10). +A document is treated as self-authored only when the **local trust store** +(§2.4) says so: + +- A document **created in Loki** on this machine gets a trust-store entry + at creation time. If the user later adds macros to it *via Loki's macro + editor* (later phase), those macros are self-authored and may run without + the enable step (capability prompts still apply). +- A document that **arrives from anywhere else** (file manager, download, + email, sync folder, collaboration server) has no entry and is untrusted — + even if its metadata claims the user as author. +- Any **externally-made modification** to a trusted document's macro + payload (hash mismatch, §2.4) drops it back to untrusted. + +### 2.3 The enable flow + +From the infobar (or File ▸ Document Security), the user can choose: + +| Choice | Effect | +|---|---| +| **Keep disabled** (default) | Payload preserved; nothing executes. Sticky — the infobar collapses to a status-bar chip on subsequent opens. | +| **Enable for this session** | Trust until the document is closed. Not persisted. | +| **Trust this document** | Persistent trust-store entry bound to the macro-payload hash. Re-prompted if the payload changes. | + +Enabling **only** permits execution of explicitly-invoked macros with the +baseline capability set (§5.2). It does **not** grant on-open auto-run +(§5.6) or any sensitive capability — those are separate decisions. + +### 2.4 The trust store + +A per-user, local, versioned store (same app-data directory family as the +spell-checker dictionary cache), **outside every document**: + +``` +TrustRecord { + doc_key: Sha256, // content hash of the *macro payload* (canonicalised) + origin_path: Option, // advisory display only, never used for matching + decision: Disabled | SessionOnly | Trusted, + auto_run_open: bool, // §5.6 — separate opt-in + capability_grants: Vec<(Capability, GrantScope)>, // §5.4 + created / last_used timestamps, +} +``` + +- Keyed by the **hash of the macro payload**, not the file path: renaming + or copying a trusted file keeps trust; *changing the macros* revokes it. +- Nothing in the store is written into the document; nothing in the + document is read into a trust decision. +- A management UI lists all records with one-click revocation (§9.4). +- The store is advisory data about *local* decisions; it does not sync via + the collaboration server in v1. + +### 2.5 Signed macros / trusted publishers — deferred + +VBA project signatures (MS-OSHARED) and ODF macro signatures could support +a "trusted publisher" tier later. **Deferred** (phase 8): signature +verification is a large, security-critical surface (X.509 chains, +timestamping, legacy digest agility) and the per-document model above is +sufficient for v1. Signature parts are preserved opaquely (consistent with +`loki-opc`'s existing signature policy). + +--- + +## 3. M002 — Storage: preserve first, byte-for-byte + +### 3.1 Today's behaviour is data loss + +The OOXML importer (`docx/import_package.rs`) walks only known relationship +types and `assemble_docx_kind` builds a **fresh** package on export, so +`vbaProject.bin` / `vbaData.xml` are silently destroyed on save. The ODF +reader (`OdfPackage::open`) extracts a fixed part list; `Basic/`, +`Scripts/`, and `` are dropped the same way. Fixing this is +**Phase 1** and is valuable even if execution never ships: Loki must stop +corrupting other people's documents. + +### 3.2 The macro payload lives in the provenance layer + +Following ADR-0002 (`DocumentSource` carries provenance, not document +content), macro payloads attach to `DocumentSource`, **not** to the +document body and **not** to the Loro CRDT: + +```rust +// loki-doc-model — provenance layer +pub struct MacroPayload { + pub kind: MacroPayloadKind, // OoxmlVba | OdfBasic + pub parts: Vec, // name, media type, raw bytes + pub event_bindings: Vec, // detected, for UI/warning only + pub payload_hash: Sha256, // trust-store key (§2.4) +} +``` + +- **OOXML:** `word/vbaProject.bin` (CFB), `word/vbaData.xml`, their + relationship entries, and the content-type overrides (and the + `xl/`-rooted equivalents for XLSX). Preserved verbatim; re-emitted on + export of a macro-enabled kind. +- **ODF:** the `Basic/` and `Scripts/` subtrees, their manifest entries, + the `` element (including `script:event-listener` + bindings), and `Configurations2/` where it references scripts. +- Digital-signature parts remain opaque and untouched, per the existing + `loki-opc` policy. (Editing the document body invalidates a package + signature regardless; we do not attempt re-signing.) + +### 3.3 Format kinds and extensions + +`DocxKind` gains `MacroEnabledDocument` / `MacroEnabledTemplate` (content +types `...document.macroEnabled.main+xml` etc.); XLSX gains the `.xlsm` / +`.xltm` equivalents. Extension ↔ payload consistency is enforced at save: + +- Saving a macro-payload document to a **macro-enabled** extension → + payload re-emitted verbatim. +- Saving to a **macro-free** extension (`.docx`, `.xlsx`, `.odt` chosen + explicitly by the user) → payload stripped, with a save-dialog notice + (matches Office behaviour, prevents extension spoofing where a `.docx` + smuggles a VBA part). +- ODF has no extension split; presence of `Basic/` is governed by the + payload alone. + +### 3.4 Macro editing and write-back (later phase) + +When the macro **editor** ships (phase 7), edited VBA modules are written +back **source-only**: the `PerformanceCache`/p-code streams are omitted and +`_VBA_PROJECT` is emitted with the minimal documented header, forcing +Office to recompile from source. This is exactly what LibreOffice does; it +is also a security feature (an edited project can never carry stale +malicious p-code — T5). Until then, payloads are never modified. + +### 3.5 Conversion policy + +`loki-convert` (headless) and in-app "save as other format" **drop** macro +payloads on cross-family conversion (`.docm` → `.odt`, `.ods` → `.xlsx`, +…) and emit a typed warning (`ConversionWarning::MacrosDropped`). No +transpilation. Same-family conversions that can carry the payload do so. + +--- + +## 4. M003 — Execution engine: one interpreter, two dialects + +### 4.1 `loki-basic`: a pure tree-walking interpreter + +A new foundation-layer crate implementing lexer → parser → AST → +**tree-walking interpreter**. No JIT, no codegen, no `unsafe`, no +dependencies on I/O of any kind: + +- **iOS:** compliant by construction — interpretation only, no runtime + code generation, satisfying the no-JIT constraint. (See §11 for the + App Store *policy* dimension, which is separate from the technical one.) +- **Determinism & auditability:** a tree-walker is slow but simple; for + the macro workloads we target (document automation, UDFs) it is more + than fast enough, and simplicity is a security property here. +- The interpreter is **resumable/suspendable**: execution proceeds by + explicit fuel-metered steps (§8) and can block on a host decision + (permission prompt) or be cancelled between any two steps. + +### 4.2 Two dialect front-ends, one core + +VBA (MS-VBAL) and StarBasic are near-siblings. One AST and evaluator, with +a `Dialect` flag governing the divergences (default `Option Base`, +`ByRef`/`ByVal` defaults, `Option Compatible` semantics, string/date +coercion quirks, dialect-specific built-ins). Language surface for v1: + +- **Types:** `Variant` (dynamic core), Integer/Long/Single/Double/Boolean/ + String/Date/Object/arrays (static + dynamic, `ReDim [Preserve]`), + user-defined `Type` records, `Enum`, `Const`. +- **Procedures:** `Sub`/`Function`/`Property Get/Let/Set`, optional/named + arguments, `ParamArray`, modules + (phase 6) class modules. +- **Control flow:** full set (`If`/`Select Case`/`For`/`For Each`/ + `Do`/`While`/`GoTo` within a procedure, `Exit`, `With`). +- **Error handling:** `On Error Resume Next` / `GoTo label`, `Err` object, + `Error`/`Raise`. +- **Built-ins:** string, math, date/time, conversion, array, and + `Format`-family functions — the pure-compute standard library. + Anything that touches the outside world is *not* a built-in; it is a + host capability (§5) or refused (§7). + +### 4.3 Host interface: the interpreter has no authority + +`loki-basic` defines a single trait boundary: + +```rust +pub trait HostObject { /* late-bound property/method dispatch */ } +pub trait Host { + fn root(&self, name: &str) -> Option; // Application, ThisComponent… + fn request(&mut self, req: HostRequest) -> HostReply; // dialogs, files, everything + fn consume_fuel(&mut self, units: u64) -> FuelVerdict; +} +``` + +The interpreter can evaluate expressions and mutate its own heap; **every** +observable effect goes through `Host::request`. A `loki-basic` embedded +with an empty host is a pure calculator. This is the capability seam: the +broker (§5) *is* the `Host` implementation, and nothing the language does +can bypass it — there is no ambient global, no intrinsic I/O function, no +escape hatch to add one from script. + +### 4.4 VBA container reading: source only, ever + +A new `loki-vba` crate parses `vbaProject.bin`: CFB (compound file) walk → +`dir` stream → per-module MS-OVBA decompression → **source text** (MBCS, +transcoded via the project code page). Hard rules: + +- The **p-code / `PerformanceCache` / `_VBA_PROJECT` compiled streams are + never parsed and never executed** (T5 — VBA stomping). A stomped module + (empty source, live p-code) is treated as an *empty module*, and the + mismatch heuristic (module count/offsets vs. source presence) surfaces a + "project appears tampered" warning in the UI. +- `SRP` streams, designer/OFORMS streams: ignored in v1 (preserved as + bytes in the payload, invisible to execution). +- The parser is fuzzed (§12) and returns typed errors; a malformed project + degrades to "macros unreadable — preserved but cannot be enabled". + +StarBasic sources are plain XML text inside the ODF package +(`Basic/*/…​.xml`, `script-lb.xml`/`script-lc.xml` library manifests) and +are parsed by `loki-odf` with the existing hardened `quick-xml` stack. + +--- + +## 5. M004 — Capability system: deny by default, grant by decision + +### 5.1 Model + +Every effectful operation is mapped to a **capability**. The broker (the +`Host` implementation in `loki-macro-host`) checks each `HostRequest` +against the grant table; a missing grant either raises a **prompt** (first +use, §5.4) or returns a **typed denial** the script sees as a trappable +BASIC runtime error (so well-written macros degrade gracefully). + +### 5.2 Capability catalog + +| Capability | Contents | Default when doc enabled | Notes | +|---|---|---|---| +| `DocRead` | read host document model, selection, metadata | **granted** | the baseline that makes macros useful | +| `DocWrite` | mutate the *host* document via the object model | prompt | all writes batched into CRDT transactions → one undo entry per run (§6.2) | +| `UiDialog` | `MsgBox`, `InputBox`, status text | prompt | badged + rate-limited (§5.5) | +| `Clipboard` | read / write system clipboard | prompt (separate read vs write) | classic exfil/injection channel | +| `FileRead` | read a file **chosen by the user through the OS picker** | picker == consent | no path-string API; see §5.3 | +| `FileWrite` | write to a picker-chosen target | picker == consent | ditto; no overwrite-without-picker | +| `Print` | submit the document to the print flow | prompt | uses the existing print path | +| `Network` | outbound HTTP(S) fetch | **refused in v1** | v2 at earliest, per-host prompts, no raw sockets — see §14 | + +Everything not in this table is **refused** (§7). The catalog is a closed +enum in code; adding a capability is a spec-level change, not a patch. + +### 5.3 File access is picker-mediated, never path-addressed + +The single biggest compat break in the capability design: `Open "C:\…"`, +`FileSystemObject`, `Dir()`, `Kill`, `Name`, `MkDir` **do not exist**. +Scripts that need a file call the object-model equivalents +(`Application.OpenFileForReading(filter…)` shim), which raise the OS file +picker; the user's pick *is* the grant, scoped to that handle, for that +run. This eliminates T3 structurally — a macro cannot enumerate, address, +or touch anything the user didn't hand it — and matches the platform +sandboxing direction on iOS/Android anyway (where the vendored +`loki-file-access` URI-permission patches already work this way). + +### 5.4 Grant scopes and prompting + +Prompts are asked **at first use during a run** (the interpreter suspends; +§4.1), not as an up-front wall — users decide with the macro's purpose in +view. Each prompt offers: + +- **Deny** (default button) → trappable error to the script. +- **Allow once** — this run only. +- **Allow for this session** — until the document closes. +- **Always for this document** — persisted to the trust record (§2.4), + listed and revocable in the management UI. + +There is deliberately **no "always for all documents"** scope. + +### 5.5 Anti-spoofing for macro UI + +Macro-originated dialogs (T7) render inside a visually distinct frame: +a "Macro: " badge header in a reserved accent style that app +chrome never uses, with the host document title. Dialog storms are +rate-limited (token bucket, e.g. 5 dialogs / 10 s; exceeding it suspends +the macro with a "misbehaving macro" infobar offering Stop). `MsgBox` +button results are returned normally so benign flows work. + +### 5.6 Auto-run events are a separate, scarier decision + +Even for a **trusted** document, on-open/auto events (`AutoOpen`, +`AutoExec`, `Document_Open`, `Workbook_Open`, ODF `OnLoad`/`OnStartApp` +listeners, `Auto_Open` in sheets) do **not** fire unless the trust record +has `auto_run_open = true`, set only via an explicit, separately-worded +opt-in ("Run this document's macros automatically when it opens — +recommended only for documents you created"). Explicit invocation (Tools ▸ +Macros ▸ Run, assigned buttons) is the normal path. On-close/on-save +events follow the same flag. This single rule neutralises T1, the vector +behind essentially all macro malware campaigns. + +--- + +## 6. M005 — Object model bridge + +### 6.1 Facades over the neutral model + +`loki-macro-host` exposes per-app object models as `HostObject` facades +over the existing neutral models — **not** over app internals: + +- **Text (`loki-text`):** `Application`, `ActiveDocument` → `Document`, + `Range`, `Selection`, `Paragraphs`, `Characters`, `Find` (phase 6), + basic formatting properties mapping onto `ParaProps`/`CharProps`. +- **Spreadsheet:** `Application`, `ActiveWorkbook`/`ThisWorkbook`, + `Worksheets`, `Range`/`Cells` (`Value`, `Formula`, `NumberFormat`), + `Names`. UDF entry point for cell formulas (§6.3). +- **Presentation:** deferred until the app matures (phase 6+). + +The facades expose **only the host document** (T12): there is no writable +`Documents`/`Workbooks` collection over other open tabs, no template +object, no `VBProject`/`VBE` self-modification API, no `Application.Run` +across documents. + +### 6.2 Writes are CRDT transactions + +All `DocWrite` mutations funnel through the same Loro mutation path the +editor uses (ADR-0006), batched so **one macro run = one undo entry**. +This gives rollback-by-undo for free, keeps collaboration coherent (a +macro edit is an ordinary local edit), and means a runaway-but-permitted +macro is recoverable with ⌘Z. + +### 6.3 Spreadsheet UDFs run compute-only + +A user-defined function referenced from a cell formula executes with +**zero** capabilities — not even `DocRead` beyond its arguments; no +prompts are possible during recalc. A UDF that attempts any `HostRequest` +returns `#MACRO!`. Tight per-call fuel (§8). This keeps recalculation +pure, fast, and unpromptable. + +### 6.4 StarBasic / UNO shim + +No general UNO bridge (`createUnoService` is refused — it is the StarBasic +equivalent of COM). A thin shim maps the *common benign idioms* onto the +same facades: `ThisComponent` → active document, `ThisComponent.getText()` +/ text-cursor enumeration, sheet `getCellByPosition`-family, and +`com.sun.star.awt.MessageBox`-style alerts → `UiDialog`. The shim's +surface is an explicit allowlist that grows by demand, never by default. + +--- + +## 7. M006 — The "never" list (permanent compatibility breaks) + +The following are **refused unconditionally** — no capability, no prompt, +no configuration flag can enable them. Each raises a distinct, documented +runtime error (`ErrFeatureRefused`, with the feature named) so authors +understand the failure. Preserved payloads may *contain* them; they simply +never execute. + +| Refused | VBA / StarBasic surface | Why | +|---|---|---| +| Process execution | `Shell`, `WScript.Shell`, `Environ$` write, `SendKeys` | the dropper endgame (T2) | +| FFI | `Declare Function … Lib`, `DllCall` | arbitrary native code | +| COM / OLE automation | `CreateObject`, `GetObject`, `New` on external ProgIDs, ActiveX | unbounded external surface (T2, T4) | +| UNO service manager | `createUnoService`, `createUnoStruct` (beyond the §6.4 shim) | same, StarBasic flavour | +| Path-addressed file I/O | `Open…For`, `FileSystemObject`, `Dir`, `Kill`, `Name`, `MkDir`, `RmDir`, `FileCopy`, `SetAttr` | replaced by picker-mediated handles (§5.3, T3) | +| Registry / OS settings | `GetSetting`/`SaveSetting`, `RegRead`… | persistence & recon | +| p-code execution | `_VBA_PROJECT`/`PerformanceCache` streams | undocumented, stomping vector (T5) | +| Excel 4.0 XLM macros | macro sheets, `=EXEC()` etc. | legacy pure-malware surface (T6); sheets render as inert data with a warning chip | +| DDE | `DDEInitiate`… | legacy exec vector | +| Remote/attached template code | template macros auto-loaded via `attachedTemplate` URLs | remote macro injection (T11) | +| Timer-based background execution | `Application.OnTime`, `Wait`-loop scheduling | macros run only in a user-visible, cancellable session (§8) | +| Add-in / startup-path loading | global template & add-in directories | nothing executes that didn't arrive in the opened document | +| VBE self-modification | `VBProject`, `CodeModule` object model | self-rewriting malware (T12) | + +This table is normative: the interpreter and broker ship with tests +asserting each row raises `ErrFeatureRefused` (§12). + +--- + +## 8. M007 — Resource limits + +- **Fuel metering:** every AST step consumes fuel; a run gets a default + budget (config constant, order 10⁸ steps) — exhausting it suspends with + a "macro is taking a long time — Continue / Stop" infobar. UDFs get a + much smaller fixed budget with **no** continue option. +- **Memory caps:** interpreter heap (strings, arrays, objects) accounted + and capped (order 256 MiB); exceeding → runtime error, not OOM. +- **Recursion/depth caps** and per-run **wall-clock watchdog**. +- **Threading:** macros execute on a worker thread; the UI thread renders + progress and the always-available **Stop** control. Document mutation + batches apply via the normal signal path on the UI thread. +- **No sleep/background scheduling** (§7) — a macro is always a foreground, + user-attributable activity with a visible stop affordance. + +--- + +## 9. M008 — UI/UX + +All strings via `loki_i18n` — new domain `macros.ftl` (registered in +`DOMAINS`, per the loader convention). Interactive elements meet the +44×44 px touch-target rule. + +1. **Infobar** (new `appthere-ui` component, `AtInfobar`): non-modal strip + under the ribbon — "This document contains macros. Macros are disabled." + with `[Enable options…]` opening the trust dialog (an `AtConfirmDialog` + derivative with the three §2.3 choices, wired like the existing + `loki_spell::Consent` gate). Collapses to a status-bar `notice_chip` + ("⚠ macros disabled") on later opens. +2. **Permission prompts** (§5.4): capability name, plain-language + consequence line, macro + document identity, Deny as default button. +3. **Macro runner:** Tools ▸ Macros — list projects/modules/procedures, + Run, per-run status line, Stop. +4. **Document Security panel:** per-document trust state, granted + capabilities with revoke buttons, auto-run toggle (§5.6), "forget this + document", and the global trust-store list. +5. **Tamper warning** when the VBA project fails the stomping heuristic + (§4.4). +6. **Macro viewer** (read-only source view, phase 3) — visibility before + executability: users (and reviewers) can inspect what a macro does + before enabling anything. + +--- + +## 10. M009 — Server & headless policy + +- `loki-server`, `loki-server-collab`, `loki-headless`, `loki-convert`, + `loki-print`: macro payloads are **opaque bytes**. Preserved through + storage/collab; **never parsed beyond presence detection, never + executed**. There is no server-side interpreter dependency at all — + enforced by keeping `loki-basic`/`loki-macro-host` out of every server + crate's dependency graph (extend `scripts/check-dependency-direction.py` + with a denial edge, §12). +- Headless conversion applies §3.5 (preserve within family, strip with + warning across families). +- Collaboration: the payload rides the document container/provenance + layer, not the Loro op stream, in v1. Trust remains local per user + (§2.4) — a collaborator's "trusted" never propagates. + +--- + +## 11. M010 — Platform notes (iOS foremost) + +- **Technical:** the engine is a pure interpreter (§4.1); there is no JIT + anywhere in the design, so iOS's W^X / no-JIT constraint is satisfied by + construction, with a single codebase for all platforms (no + interpreter-vs-JIT split to maintain). +- **Policy:** App Store Guideline 2.5.2 restricts executing downloaded + code; document macros are exactly that. The execution engine is + therefore behind a **build-time feature flag** (`macro-exec`): the iOS + build can ship *preservation + viewer only* (still a major win — no + data loss, full transparency) if App Review requires, without forking + the codebase. Android/desktop ship with execution enabled. +- Fuel-metered stepping (§8) doubles as the mobile ANR guard. + +--- + +## 12. M011 — Crate layout, and M012 — verification + +### New crates (ADR-0009 layer map additions) + +| Crate | Layer | Deps (internal) | Responsibility | +|---|---|---|---| +| `loki-basic` | L1 | `loki-primitives` | lexer/parser/AST/interpreter, `Host` trait, fuel. Zero I/O deps; `#![forbid(unsafe_code)]`. | +| `loki-vba` | L2 | — (external: a pure-Rust `cfb` reader) | `vbaProject.bin` CFB walk, MS-OVBA decompression, dir-stream parse, source extraction, stomping heuristic. | +| `loki-macro-host` | L5 | `loki-basic`, `loki-doc-model`, `loki-sheet-model` | capability broker (`Host` impl), trust store, object-model facades, `MacroService` (provided via `provide_context`, `SpellService` pattern). | + +StarBasic container parsing lives in `loki-odf`; payload preservation +touches `loki-opc` consumers (`loki-ooxml`, `loki-odf`) and +`loki-doc-model::io::DocumentSource`. UI components land in `appthere-ui` +(`AtInfobar`, permission dialog) and per-app wiring in the three apps. + +### Verification (CI-gated) + +- **Fuzzing:** `cargo-fuzz` targets for the CFB/OVBA reader, the ODF + script-container reader, and the `loki-basic` lexer/parser. Corpus + seeded from real-world macro documents (benign) and CVE-shaped + malformed containers. Run in CI on a schedule. +- **"Never" table tests:** one test per §7 row asserting + `ErrFeatureRefused`. +- **Malware-pattern regression corpus:** sanitised auto-open dropper + skeletons (no live payloads) asserting: not executed on open; T5 stomped + project treated as empty; XLM sheets inert. +- **Capability tests:** every `HostRequest` kind × {no grant → prompt or + typed denial; grant scopes honoured; revocation immediate}. +- **Round-trip goldens:** `.docm`/`.xlsm`/ODT-with-Basic load→save byte + comparison of preserved parts (`loki-acid` fixtures). +- **Dependency gates:** `check-dependency-direction.py` extended: server + crates must not depend on `loki-basic`/`loki-macro-host`; `loki-basic` + must not depend on any I/O-capable crate. +- Interpreter conformance suite: language-semantics tests shared across + both dialects (numeric coercion, error handling, `Variant` edge cases). + +--- + +## 13. Data-loss note on today's behaviour (why Phase 1 is urgent) + +Independent of everything above: **Loki currently strips macros from every +macro-enabled document a user saves**, silently. Even users who never want +macro *execution* are having their files damaged. Phase 1 (preservation + +"macros present" indicator) is a correctness fix and should land ahead of, +and independent from, any execution work. + +--- + +## 14. Implementation phases + +Each phase is independently shippable and independently reviewable; later +phases can be dropped or reordered without stranding earlier ones. + +| Phase | Deliverable | Key crates | Exit criteria | +|---|---|---|---| +| **1. Preserve & detect** | `MacroPayload` on `DocumentSource`; OOXML + ODF payload preservation; macro-enabled `DocxKind`s; extension-strip rule (§3.3); conversion warnings (§3.5); infobar/chip "macros present (not executed)" | `loki-opc` consumers, `loki-doc-model`, `appthere-ui` | round-trip goldens byte-identical; no execution surface exists | +| **2. Interpreter core** | `loki-basic`: full language §4.2, empty-host mode, fuel, suspension; conformance suite; parser fuzzing | `loki-basic` | passes conformance suite; fuzzers clean; zero I/O deps enforced | +| **3. Source extraction & viewer** | `loki-vba` (source-only, stomping heuristic); ODF Basic reader; read-only macro viewer UI | `loki-vba`, `loki-odf`, apps | real-world corpus parses or degrades typed; tamper warning works | +| **4. Trust & capability infrastructure** | trust store, capability broker, permission prompts, Document Security panel, anti-spoof dialog frame | `loki-macro-host`, `appthere-ui` | capability test matrix green; T10 tests green | +| **5. Execution v1 — text + spreadsheet** | object-model facades (§6.1), `DocRead`/`DocWrite`/`UiDialog`/`Clipboard`/`Print`, explicit run only, CRDT-batched undo, Stop control | `loki-macro-host`, apps | "never" table tests green; malware corpus inert; macro run = 1 undo entry | +| **6. Events & UDFs** | button/control-assigned macros; spreadsheet UDFs (compute-only, `#MACRO!`); on-open events behind `auto_run_open` (§5.6); `Find`, class modules | same | T1 regression corpus: nothing fires without the flag | +| **7. Macro editor** | edit + save-back (source-only write, §3.4) for self-authored docs; picker-mediated `FileRead`/`FileWrite` | `loki-vba`, apps | edited projects reopen in Office/LO from source | +| **8. Extended trust (optional)** | signature verification / trusted publishers (§2.5); `Network` capability with per-host prompts — **each requires its own spec addendum before implementation** | new | — | + +--- + +## 15. Open decisions for approval + +Recommendations marked ✦; everything else in this spec follows from them. + +| # | Decision | Options | ✦ Recommendation | +|---|---|---|---| +| D1 | iOS ships execution? | (a) preserve+viewer only; (b) full engine behind `macro-exec` flag, decide at App Review | ✦ (b) — build the flag either way (§11), attempt review with execution on | +| D2 | `Network` capability | never / v2 with per-host prompts | ✦ v2 at earliest, own spec addendum; refuse in v1 (§5.2) | +| D3 | UserForms | never / later native-widget subset | ✦ later subset (phase ≥8); many benign macros use simple forms, but MS-OFORMS parsing is a big attack surface — not v1 | +| D4 | Trust-store sync across user's devices | local-only / sync via server | ✦ local-only v1; syncing trust is a security decision with collab-server implications | +| D5 | Presentation-app object model | phase 6 / indefinite defer | ✦ defer until the app matures; payload preservation covers `.pptm`/ODP scripts from phase 1 | + +--- + +*Once this spec is approved, Phase 1 begins with the preservation work in +§3 — which is a data-integrity fix worth landing regardless of the +execution roadmap.* From 32ff0376f6ee6349fbf89f403db9b358f0cb148b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:22:50 +0000 Subject: [PATCH 02/71] Macro Phase 1: preserve VBA payloads through OOXML round-trip Adds MacroPayload to the doc-model provenance layer (DocumentSource.macros) with an order-independent, content-addressed payload hash for the future trust store. Teaches DOCX and XLSX import to collect vbaProject.bin (+ Word's vbaData.xml) into the payload, and export to re-emit it verbatim only for macro-enabled kinds (.docm/.dotm/.xlsm), stripping it for plain .docx/.xlsx to match Office extension semantics. VBA bytes are never parsed or executed. - loki-doc-model: io::macros (MacroPayload/PreservedPart/MacroPayloadKind) - loki-ooxml: shared crate::vba collect/emit; DocxKind macro variants + DocxMacroEnabledExport/TemplateExport; XlsxImport::run + XlsxImportResult.macros + XlsxExport::export_with_macros - Relocated cell_ref_to_coord to import_worksheet.rs (file-ceiling) - Round-trip tests: byte-identical project preservation, stable payload hash, plain-export stripping Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB --- Cargo.lock | 1 + docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md | 20 +-- loki-doc-model/Cargo.toml | 1 + loki-doc-model/src/io/macros.rs | 173 +++++++++++++++++++ loki-doc-model/src/io/macros_tests.rs | 66 +++++++ loki-doc-model/src/io/mod.rs | 1 + loki-doc-model/src/io/source.rs | 18 ++ loki-ooxml/src/docx/export.rs | 40 +++++ loki-ooxml/src/docx/import_package.rs | 9 + loki-ooxml/src/docx/mod.rs | 4 + loki-ooxml/src/docx/vba_tests.rs | 186 ++++++++++++++++++++ loki-ooxml/src/docx/write/assembly.rs | 31 +++- loki-ooxml/src/lib.rs | 8 +- loki-ooxml/src/vba.rs | 221 ++++++++++++++++++++++++ loki-ooxml/src/xlsx/export.rs | 48 +++-- loki-ooxml/src/xlsx/import.rs | 57 +++--- loki-ooxml/src/xlsx/import_worksheet.rs | 31 +++- loki-ooxml/src/xlsx/mod.rs | 4 + loki-ooxml/src/xlsx/vba_tests.rs | 152 ++++++++++++++++ 19 files changed, 1017 insertions(+), 54 deletions(-) create mode 100644 loki-doc-model/src/io/macros.rs create mode 100644 loki-doc-model/src/io/macros_tests.rs create mode 100644 loki-ooxml/src/docx/vba_tests.rs create mode 100644 loki-ooxml/src/vba.rs create mode 100644 loki-ooxml/src/xlsx/vba_tests.rs diff --git a/Cargo.lock b/Cargo.lock index db0f5afa..1e1d13cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4491,6 +4491,7 @@ dependencies = [ "rustc-hash 2.1.3", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tracing", ] diff --git a/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md index 247f6140..777efee7 100644 --- a/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md +++ b/docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md @@ -2,7 +2,7 @@ # AppThere Loki — Safe Macro Scripting Spec (VBA & StarBasic) -**Status:** Draft — awaiting approval (v0.1, 2026-07-16) +**Status:** Ratified (v1, 2026-07-16) — open decisions D1–D5 accepted as recommended **Series:** AppThere Client, ADRs M001–M012 **Companions:** ADR-0002 (version-preserving round-trip), ADR-0009 (target layering), `LOKI_HEADLESS_SERVER_SPEC.md` (C021–C028 — headless policy §10) @@ -615,17 +615,17 @@ phases can be dropped or reordered without stranding earlier ones. --- -## 15. Open decisions for approval +## 15. Open decisions — resolved (2026-07-16) -Recommendations marked ✦; everything else in this spec follows from them. +All five decisions were accepted as recommended: -| # | Decision | Options | ✦ Recommendation | -|---|---|---|---| -| D1 | iOS ships execution? | (a) preserve+viewer only; (b) full engine behind `macro-exec` flag, decide at App Review | ✦ (b) — build the flag either way (§11), attempt review with execution on | -| D2 | `Network` capability | never / v2 with per-host prompts | ✦ v2 at earliest, own spec addendum; refuse in v1 (§5.2) | -| D3 | UserForms | never / later native-widget subset | ✦ later subset (phase ≥8); many benign macros use simple forms, but MS-OFORMS parsing is a big attack surface — not v1 | -| D4 | Trust-store sync across user's devices | local-only / sync via server | ✦ local-only v1; syncing trust is a security decision with collab-server implications | -| D5 | Presentation-app object model | phase 6 / indefinite defer | ✦ defer until the app matures; payload preservation covers `.pptm`/ODP scripts from phase 1 | +| # | Decision | Resolution | +|---|---|---| +| D1 | iOS ships execution? | **Accepted:** full engine behind the `macro-exec` build flag; attempt App Review with execution enabled, fall back to preserve+viewer-only if required (§11) | +| D2 | `Network` capability | **Accepted:** refused in v1 (§5.2); v2 at earliest, and only with its own spec addendum | +| D3 | UserForms | **Accepted:** deferred native-widget subset (phase ≥8), not v1 | +| D4 | Trust-store sync across user's devices | **Accepted:** local-only in v1 | +| D5 | Presentation-app object model | **Accepted:** deferred until the app matures; payload preservation still covers presentation-family scripts where the formats are supported | --- diff --git a/loki-doc-model/Cargo.toml b/loki-doc-model/Cargo.toml index 49f834ae..d7c63c2f 100644 --- a/loki-doc-model/Cargo.toml +++ b/loki-doc-model/Cargo.toml @@ -30,6 +30,7 @@ thiserror = "2" indexmap = "2" loro = "1.11.1" rustc-hash = "2" +sha2 = "0.10" tracing = "0.1" [dependencies.chrono] diff --git a/loki-doc-model/src/io/macros.rs b/loki-doc-model/src/io/macros.rs new file mode 100644 index 00000000..b7d19956 --- /dev/null +++ b/loki-doc-model/src/io/macros.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Preserved macro/script payloads (provenance layer). +//! +//! Real-world office documents carry executable content: VBA projects in +//! OOXML macro-enabled formats (`.docm`/`.xlsm`/…) and StarBasic script +//! libraries in ODF packages. Loki does **not** execute these in Phase 1; +//! it *preserves* them byte-for-byte so a load→edit→save cycle no longer +//! silently destroys them. +//! +//! Per [`LOKI_MACRO_SCRIPTING_SPEC`] §3.2, the payload attaches to the +//! provenance layer ([`super::source::DocumentSource`]) — **not** to the +//! document body and **not** to the Loro CRDT. Nothing in this module +//! executes, interprets, or trusts the bytes it carries; it is inert +//! storage plus a canonical content hash used later as the trust-store key +//! (spec §2.4). +//! +//! [`LOKI_MACRO_SCRIPTING_SPEC`]: ../../../docs/adr/LOKI_MACRO_SCRIPTING_SPEC.md + +use sha2::{Digest, Sha256}; + +/// Which macro/script family a [`MacroPayload`] came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum MacroPayloadKind { + /// OOXML VBA project (`vbaProject.bin` + `vbaData.xml`), CFB-encoded. + OoxmlVba, + /// ODF StarBasic/Basic script libraries (`Basic/`, `Scripts/`, + /// `` bindings). + OdfBasic, +} + +/// A single container part preserved verbatim. +/// +/// The `bytes` are opaque: no parsing, decompression, or validation is +/// performed on them at the model layer. `name` is the format-native part +/// path (OOXML part name like `/word/vbaProject.bin`, or ODF ZIP entry like +/// `Basic/Standard/Module1.xml`); `media_type` is the content-type override +/// or manifest media type where the format records one. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PreservedPart { + /// Format-native part path / ZIP entry name. + pub name: String, + /// Recorded media type, if the format supplies one for this part. + pub media_type: Option, + /// Raw bytes, preserved verbatim. + pub bytes: Vec, +} + +impl PreservedPart { + /// Creates a preserved part. + #[must_use] + pub fn new(name: impl Into, media_type: Option, bytes: Vec) -> Self { + Self { + name: name.into(), + media_type, + bytes, + } + } +} + +/// A macro auto-execution binding detected at import, kept for UI and +/// warning purposes **only** (spec §5.6, §9). +/// +/// This is descriptive metadata — the presence of a `Document_Open` or +/// ODF `OnLoad` listener is surfaced to the user so the security UI can +/// explain *why* a document wants to run code on open. It never drives +/// execution; Phase 1 has no execution surface at all. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct RawEventBinding { + /// The event name as recorded by the format (e.g. `"Document_Open"`, + /// `"OnLoad"`, `"Auto_Open"`). + pub event: String, + /// The macro/script the binding targets, if named by the format. + pub target: Option, +} + +/// A preserved, inert macro/script payload attached to a document's +/// provenance. +/// +/// Held on [`super::source::DocumentSource`]. Importers populate it; +/// exporters re-emit [`Self::parts`] verbatim when writing back to a +/// macro-capable format, or drop it (with a warning) when the target format +/// cannot carry it (spec §3.3, §3.5). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MacroPayload { + /// Which family this payload belongs to. + pub kind: MacroPayloadKind, + /// The preserved container parts, in a stable order. + pub parts: Vec, + /// Auto-execution bindings detected at import (advisory; spec §5.6). + pub event_bindings: Vec, +} + +impl MacroPayload { + /// Creates a payload from its parts, with no detected event bindings. + #[must_use] + pub fn new(kind: MacroPayloadKind, parts: Vec) -> Self { + Self { + kind, + parts, + event_bindings: Vec::new(), + } + } + + /// Builder: attach detected auto-run event bindings. + #[must_use] + pub fn with_event_bindings(mut self, bindings: Vec) -> Self { + self.event_bindings = bindings; + self + } + + /// Returns `true` if the payload carries no parts. + #[must_use] + pub fn is_empty(&self) -> bool { + self.parts.is_empty() + } + + /// Computes the canonical content hash used as the trust-store key + /// (spec §2.4). + /// + /// The hash is a deterministic function of the payload's *content* — + /// the kind and every part's name, media type, and bytes — and is + /// **independent of part ordering** (parts are sorted by name before + /// hashing) and of anything outside the payload (file path, timestamps, + /// the rest of the document). Two documents with byte-identical macro + /// content therefore share a key, so renaming or copying a trusted file + /// keeps trust while *changing the macros* revokes it. + /// + /// `event_bindings` are derived from the parts, so they are intentionally + /// excluded from the hash to avoid double-counting. + #[must_use] + pub fn payload_hash(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + // Domain separation + kind, so the two families never collide. + hasher.update(b"loki-macro-payload\x00"); + let kind_tag: u8 = match self.kind { + MacroPayloadKind::OoxmlVba => 1, + MacroPayloadKind::OdfBasic => 2, + }; + hasher.update([kind_tag]); + + let mut ordered: Vec<&PreservedPart> = self.parts.iter().collect(); + ordered.sort_by(|a, b| a.name.cmp(&b.name)); + for part in ordered { + // Length-prefix every field so no concatenation ambiguity can + // let two distinct payloads hash the same. + write_len_prefixed(&mut hasher, part.name.as_bytes()); + match &part.media_type { + Some(mt) => { + hasher.update([1u8]); + write_len_prefixed(&mut hasher, mt.as_bytes()); + } + None => hasher.update([0u8]), + } + write_len_prefixed(&mut hasher, &part.bytes); + } + hasher.finalize().into() + } +} + +fn write_len_prefixed(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + +#[cfg(test)] +#[path = "macros_tests.rs"] +mod tests; diff --git a/loki-doc-model/src/io/macros_tests.rs b/loki-doc-model/src/io/macros_tests.rs new file mode 100644 index 00000000..3183cdf8 --- /dev/null +++ b/loki-doc-model/src/io/macros_tests.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; + +fn part(name: &str, bytes: &[u8]) -> PreservedPart { + PreservedPart::new(name, Some("application/octet-stream".into()), bytes.to_vec()) +} + +#[test] +fn empty_payload_reports_empty() { + let p = MacroPayload::new(MacroPayloadKind::OoxmlVba, Vec::new()); + assert!(p.is_empty()); +} + +#[test] +fn hash_is_order_independent() { + let a = MacroPayload::new( + MacroPayloadKind::OoxmlVba, + vec![part("/word/vbaProject.bin", b"AAA"), part("/word/vbaData.xml", b"BBB")], + ); + let b = MacroPayload::new( + MacroPayloadKind::OoxmlVba, + vec![part("/word/vbaData.xml", b"BBB"), part("/word/vbaProject.bin", b"AAA")], + ); + assert_eq!(a.payload_hash(), b.payload_hash()); +} + +#[test] +fn hash_changes_when_bytes_change() { + let a = MacroPayload::new(MacroPayloadKind::OoxmlVba, vec![part("/word/vbaProject.bin", b"AAA")]); + let b = MacroPayload::new(MacroPayloadKind::OoxmlVba, vec![part("/word/vbaProject.bin", b"AAB")]); + assert_ne!(a.payload_hash(), b.payload_hash()); +} + +#[test] +fn hash_distinguishes_kinds() { + let vba = MacroPayload::new(MacroPayloadKind::OoxmlVba, vec![part("x", b"AAA")]); + let basic = MacroPayload::new(MacroPayloadKind::OdfBasic, vec![part("x", b"AAA")]); + assert_ne!(vba.payload_hash(), basic.payload_hash()); +} + +#[test] +fn length_prefix_prevents_boundary_collision() { + // Without length-prefixing, ("ab","c") and ("a","bc") would concatenate + // identically. They must hash differently. + let a = MacroPayload::new( + MacroPayloadKind::OoxmlVba, + vec![PreservedPart::new("ab", None, b"c".to_vec())], + ); + let b = MacroPayload::new( + MacroPayloadKind::OoxmlVba, + vec![PreservedPart::new("a", None, b"bc".to_vec())], + ); + assert_ne!(a.payload_hash(), b.payload_hash()); +} + +#[test] +fn event_bindings_do_not_affect_hash() { + let base = MacroPayload::new(MacroPayloadKind::OoxmlVba, vec![part("m", b"AAA")]); + let with_binding = base.clone().with_event_bindings(vec![RawEventBinding { + event: "Document_Open".into(), + target: Some("Module1.AutoOpen".into()), + }]); + assert_eq!(base.payload_hash(), with_binding.payload_hash()); +} diff --git a/loki-doc-model/src/io/mod.rs b/loki-doc-model/src/io/mod.rs index 3e813e0e..3059981f 100644 --- a/loki-doc-model/src/io/mod.rs +++ b/loki-doc-model/src/io/mod.rs @@ -7,6 +7,7 @@ //! [`DocumentExport`] to convert between their native formats and the //! abstract [`crate::Document`] model. +pub mod macros; pub mod source; use crate::document::Document; diff --git a/loki-doc-model/src/io/source.rs b/loki-doc-model/src/io/source.rs index 205482e6..b5224649 100644 --- a/loki-doc-model/src/io/source.rs +++ b/loki-doc-model/src/io/source.rs @@ -7,6 +7,8 @@ //! records which format and version it came from. This allows exporters //! to make format-version-aware decisions. +use crate::io::macros::MacroPayload; + /// Provenance of a document loaded from a file. /// /// Populated by format-specific importers (`loki-odf`, `loki-ooxml`). @@ -34,6 +36,14 @@ pub struct DocumentSource { /// /// ODF: `meta:generator`. OOXML: `AppVersion` in `app.xml`. pub generator: Option, + + /// Preserved macro/script payload, if the source file carried one. + /// + /// Populated by importers for macro-enabled OOXML and ODF-with-Basic + /// documents. Loki does not execute this in Phase 1; it is retained so + /// exporters can re-emit it verbatim (spec §3), avoiding the silent + /// macro loss that a fresh-package export would otherwise cause. + pub macros: Option, } impl DocumentSource { @@ -44,6 +54,7 @@ impl DocumentSource { format: format.into(), version: None, generator: None, + macros: None, } } @@ -60,6 +71,13 @@ impl DocumentSource { self.generator = Some(generator.into()); self } + + /// Builder: attach a preserved macro/script payload. + #[must_use] + pub fn with_macros(mut self, macros: MacroPayload) -> Self { + self.macros = Some(macros); + self + } } #[cfg(test)] diff --git a/loki-ooxml/src/docx/export.rs b/loki-ooxml/src/docx/export.rs index 8253d5c9..5764b6b1 100644 --- a/loki-ooxml/src/docx/export.rs +++ b/loki-ooxml/src/docx/export.rs @@ -49,6 +49,46 @@ impl DocumentExport for DocxTemplateExport { } } +/// Unit struct that implements [`DocumentExport`] for a **macro-enabled** +/// Word document (`.docm`). +/// +/// Structurally a `.docx`, but the main part carries the macro-enabled +/// content type and any VBA payload preserved on `doc.source.macros` is +/// re-emitted verbatim (spec §3.3). Saving a macro-carrying document through +/// the plain [`DocxExport`] instead deliberately strips the macros, matching +/// Office's extension semantics. +pub struct DocxMacroEnabledExport; + +impl DocumentExport for DocxMacroEnabledExport { + type Error = OoxmlError; + type Options = (); + + fn export( + doc: &Document, + writer: impl Write + Seek, + _options: Self::Options, + ) -> Result<(), Self::Error> { + assemble_docx_kind(doc, writer, DocxKind::MacroEnabledDocument) + } +} + +/// Unit struct that implements [`DocumentExport`] for a **macro-enabled** +/// Word template (`.dotm`). +pub struct DocxMacroEnabledTemplateExport; + +impl DocumentExport for DocxMacroEnabledTemplateExport { + type Error = OoxmlError; + type Options = (); + + fn export( + doc: &Document, + writer: impl Write + Seek, + _options: Self::Options, + ) -> Result<(), Self::Error> { + assemble_docx_kind(doc, writer, DocxKind::MacroEnabledTemplate) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/loki-ooxml/src/docx/import_package.rs b/loki-ooxml/src/docx/import_package.rs index 8fbea2c0..8a4e3566 100644 --- a/loki-ooxml/src/docx/import_package.rs +++ b/loki-ooxml/src/docx/import_package.rs @@ -192,6 +192,15 @@ pub(crate) fn parse_and_map_package( // content flow as Inline::Comment). document.comments = comments; + // Preserve any VBA macro payload (spec §3, Phase 1). Not executed — kept so + // a round-trip to a macro-enabled extension does not silently strip it. + if let Some(payload) = crate::vba::collect(package, &doc_part_name) { + document + .source + .get_or_insert_with(|| loki_doc_model::io::source::DocumentSource::new("ooxml")) + .macros = Some(payload); + } + Ok((document, warnings)) } diff --git a/loki-ooxml/src/docx/mod.rs b/loki-ooxml/src/docx/mod.rs index 940d18d9..443ba603 100644 --- a/loki-ooxml/src/docx/mod.rs +++ b/loki-ooxml/src/docx/mod.rs @@ -25,3 +25,7 @@ pub(crate) mod omml; pub(crate) mod reader; pub mod repair; pub(crate) mod write; + +#[cfg(test)] +#[path = "vba_tests.rs"] +mod vba_tests; diff --git a/loki-ooxml/src/docx/vba_tests.rs b/loki-ooxml/src/docx/vba_tests.rs new file mode 100644 index 00000000..c183a0d8 --- /dev/null +++ b/loki-ooxml/src/docx/vba_tests.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::io::Cursor; + +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::io::macros::MacroPayloadKind; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::docx::export::DocxMacroEnabledExport; +use crate::docx::import::{DocxImportOptions, DocxImporter}; +use crate::vba::{REL_VBA_PROJECT, REL_WORD_VBA_DATA}; + +/// Fake but structurally valid VBA project bytes. Loki never parses these; +/// the test only checks they survive the round-trip verbatim. +const FAKE_VBA: &[u8] = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1FAKE-CFB-VBA-PROJECT-BYTES"; +const FAKE_VBA_DATA: &[u8] = br#""#; + +/// Builds an in-memory `.docm` package: minimal document body + a VBA project +/// (`vbaProject.bin`) and its `vbaData.xml`, wired with the standard MS rels. +fn build_docm() -> Vec { + let mut pkg = Package::new(); + + let doc_part = PartName::new("/word/document.xml").unwrap(); + let vba_part = PartName::new("/word/vbaProject.bin").unwrap(); + let vba_data_part = PartName::new("/word/vbaData.xml").unwrap(); + + let body = br#" + +Hi"#; + + pkg.set_part( + doc_part.clone(), + PartData::new( + body.to_vec(), + "application/vnd.ms-word.document.macroEnabled.main+xml", + ), + ); + pkg.set_part( + vba_part.clone(), + PartData::new(FAKE_VBA.to_vec(), "application/vnd.ms-office.vbaProject"), + ); + pkg.set_part( + vba_data_part.clone(), + PartData::new( + FAKE_VBA_DATA.to_vec(), + "application/vnd.ms-word.vbaData+xml", + ), + ); + + pkg.content_type_map_mut().add_default( + "rels", + "application/vnd.openxmlformats-package.relationships+xml", + ); + pkg.content_type_map_mut().add_default("xml", "application/xml"); + pkg.content_type_map_mut().add_override( + &doc_part, + "application/vnd.ms-word.document.macroEnabled.main+xml", + ); + pkg.content_type_map_mut() + .add_override(&vba_part, "application/vnd.ms-office.vbaProject"); + pkg.content_type_map_mut() + .add_override(&vba_data_part, "application/vnd.ms-word.vbaData+xml"); + + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + .into(), + target: "word/document.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + + pkg.part_relationships_mut(&doc_part) + .add(Relationship { + id: "rId100".into(), + rel_type: REL_VBA_PROJECT.into(), + target: "vbaProject.bin".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + + pkg.part_relationships_mut(&vba_part) + .add(Relationship { + id: "rId1".into(), + rel_type: REL_WORD_VBA_DATA.into(), + target: "vbaData.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + + let mut buf = Cursor::new(Vec::new()); + pkg.write(&mut buf).unwrap(); + buf.into_inner() +} + +#[test] +fn import_collects_vba_payload() { + let bytes = build_docm(); + let result = DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes)) + .expect("import"); + let macros = result + .document + .source + .as_ref() + .and_then(|s| s.macros.as_ref()) + .expect("macro payload preserved"); + + assert_eq!(macros.kind, MacroPayloadKind::OoxmlVba); + let project = macros + .parts + .iter() + .find(|p| p.name.ends_with("vbaProject.bin")) + .expect("vbaProject.bin part"); + assert_eq!(project.bytes, FAKE_VBA); + let data = macros + .parts + .iter() + .find(|p| p.name.ends_with("vbaData.xml")) + .expect("vbaData.xml part"); + assert_eq!(data.bytes, FAKE_VBA_DATA); +} + +#[test] +fn macro_enabled_export_preserves_project_bytes() { + let doc = import_doc(&build_docm()); + + let mut out = Cursor::new(Vec::new()); + DocxMacroEnabledExport::export(&doc, &mut out, ()).expect("export"); + let reimported = import_doc(&out.into_inner()); + + let macros = reimported + .source + .as_ref() + .and_then(|s| s.macros.as_ref()) + .expect("macros survive the round-trip"); + let project = macros + .parts + .iter() + .find(|p| p.name.ends_with("vbaProject.bin")) + .expect("vbaProject.bin"); + assert_eq!(project.bytes, FAKE_VBA, "VBA project bytes must be verbatim"); + + // The payload hash (trust-store key) is stable across the round-trip. + let original = import_doc(&build_docm()); + assert_eq!( + original + .source + .unwrap() + .macros + .unwrap() + .payload_hash(), + macros.payload_hash(), + ); +} + +#[test] +fn plain_export_strips_macros() { + let doc = import_doc(&build_docm()); + + let mut out = Cursor::new(Vec::new()); + crate::docx::export::DocxExport::export(&doc, &mut out, ()).expect("export"); + let reimported = import_doc(&out.into_inner()); + + assert!( + reimported + .source + .as_ref() + .and_then(|s| s.macros.as_ref()) + .is_none(), + "a plain .docx save must drop the VBA payload" + ); +} + +fn import_doc(bytes: &[u8]) -> Document { + DocxImporter::new(DocxImportOptions::default()) + .run(Cursor::new(bytes.to_vec())) + .expect("import") + .document +} diff --git a/loki-ooxml/src/docx/write/assembly.rs b/loki-ooxml/src/docx/write/assembly.rs index f0134b43..0a95c6b6 100644 --- a/loki-ooxml/src/docx/write/assembly.rs +++ b/loki-ooxml/src/docx/write/assembly.rs @@ -37,14 +37,24 @@ const MT_DOCUMENT: &str = /// identical to a `.docx`; only this override differs. const MT_TEMPLATE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"; +/// Main-part content type for a macro-enabled document (`.docm`). +const MT_DOCUMENT_MACRO: &str = "application/vnd.ms-word.document.macroEnabled.main+xml"; +/// Main-part content type for a macro-enabled template (`.dotm`). +const MT_TEMPLATE_MACRO: &str = "application/vnd.ms-word.template.macroEnabled.main+xml"; -/// Whether to assemble a regular document (`.docx`) or a template (`.dotx`). +/// Which DOCX flavour to assemble. The macro-enabled kinds re-emit a preserved +/// VBA payload (spec §3.3); the plain kinds strip it, matching Office's +/// extension semantics (`.docx`/`.dotx` cannot carry macros). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DocxKind { /// A normal document part (`document.main+xml`). Document, /// A template part (`template.main+xml`). Template, + /// A macro-enabled document part (`.docm`). + MacroEnabledDocument, + /// A macro-enabled template part (`.dotm`). + MacroEnabledTemplate, } impl DocxKind { @@ -53,8 +63,18 @@ impl DocxKind { match self { DocxKind::Document => MT_DOCUMENT, DocxKind::Template => MT_TEMPLATE, + DocxKind::MacroEnabledDocument => MT_DOCUMENT_MACRO, + DocxKind::MacroEnabledTemplate => MT_TEMPLATE_MACRO, } } + + /// Whether this kind may carry a preserved VBA payload. + fn is_macro_enabled(self) -> bool { + matches!( + self, + DocxKind::MacroEnabledDocument | DocxKind::MacroEnabledTemplate + ) + } } const MT_STYLES: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"; const MT_NUMBERING: &str = @@ -246,6 +266,15 @@ pub(crate) fn assemble_docx_kind( ct.add_default(ext, mime); } + // ── Preserved VBA payload (spec §3.3) ───────────────────────────────── + // Re-emit only for a macro-enabled kind; a plain `.docx`/`.dotx` save + // deliberately drops the payload (Office extension semantics). + if kind.is_macro_enabled() + && let Some(payload) = doc.source.as_ref().and_then(|s| s.macros.as_ref()) + { + crate::vba::emit(&mut pkg, &doc_part, payload, || collector.reserve_r_id())?; + } + // ── Step 5: Canonicalise child order, then write ZIP ────────────────── // The per-part serialisers emit content correctly but do not all emit // `pPr`/`rPr`/… children in the strict `xsd:sequence` order that diff --git a/loki-ooxml/src/lib.rs b/loki-ooxml/src/lib.rs index a0433543..b44c4f84 100644 --- a/loki-ooxml/src/lib.rs +++ b/loki-ooxml/src/lib.rs @@ -48,6 +48,10 @@ pub mod constants; pub mod error; pub(crate) mod xml_util; +// VBA macro-payload preservation, shared by DOCX and XLSX (spec §3, Phase 1). +#[cfg(any(feature = "docx", feature = "xlsx"))] +pub(crate) mod vba; + #[cfg(feature = "docx")] pub mod docx; @@ -60,7 +64,9 @@ pub mod pptx; pub use error::{NoteKind, OoxmlError, OoxmlResult, OoxmlWarning}; #[cfg(feature = "docx")] -pub use docx::export::{DocxExport, DocxTemplateExport}; +pub use docx::export::{ + DocxExport, DocxMacroEnabledExport, DocxMacroEnabledTemplateExport, DocxTemplateExport, +}; #[cfg(feature = "docx")] pub use docx::import::{DocxImport, DocxImportOptions, DocxImportResult}; #[cfg(feature = "docx")] diff --git a/loki-ooxml/src/vba.rs b/loki-ooxml/src/vba.rs new file mode 100644 index 00000000..b7e822af --- /dev/null +++ b/loki-ooxml/src/vba.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! VBA macro-payload preservation for OOXML (spec §3, Phase 1). +//! +//! Shared by DOCX (`.docm`/`.dotm`, main part `word/document.xml`) and XLSX +//! (`.xlsm`/`.xltm`, main part `xl/workbook.xml`): in both, the VBA project is +//! reached from the main part via the Microsoft `vbaProject` relationship. +//! +//! Loki does **not** execute VBA in Phase 1. It preserves the payload +//! byte-for-byte so that opening a macro-enabled document and saving it back +//! to a macro-enabled extension no longer silently destroys the macros (the +//! pre-Phase-1 behaviour: the importer walked only known relationship types +//! and the exporter built a fresh package, so `vbaProject.bin` vanished). +//! +//! Scope: the `vbaProject.bin` VBA project and its optional `vbaData.xml` +//! companion (Word only). The project bytes are preserved verbatim; the OPC +//! wiring (relationships, content-type overrides) is **regenerated** on export +//! to its standard, well-known values — semantically identical to the +//! original, and robust against unusual rId assignments. Loki never parses the +//! CFB container or executes p-code (that is Phase 3 / `loki-vba`). + +use loki_doc_model::io::macros::{MacroPayload, MacroPayloadKind, PreservedPart}; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::error::OoxmlError; + +// ── Microsoft VBA relationship types (MS-DOCX / MS-OSHARED) ───────────────── + +/// Relationship from the main document part to the VBA project. +pub(crate) const REL_VBA_PROJECT: &str = + "http://schemas.microsoft.com/office/2006/relationships/vbaProject"; +/// Relationship from the VBA project to its `vbaData.xml` companion. +pub(crate) const REL_WORD_VBA_DATA: &str = + "http://schemas.microsoft.com/office/2006/relationships/wordVbaData"; + +// ── VBA content types ─────────────────────────────────────────────────────── + +const CT_VBA_PROJECT: &str = "application/vnd.ms-office.vbaProject"; +const CT_VBA_DATA: &str = "application/vnd.ms-word.vbaData+xml"; + +/// Standard suffixes used to classify preserved parts on export. +const SUFFIX_VBA_PROJECT: &str = "vbaProject.bin"; +const SUFFIX_VBA_DATA: &str = "vbaData.xml"; + +/// Collects the VBA payload from an imported package, if present. +/// +/// Follows the `vbaProject` relationship from the main document part, then +/// the `wordVbaData` relationship from the project part. Returns `None` when +/// the document declares no VBA project. +pub(crate) fn collect(package: &Package, doc_part: &PartName) -> Option { + let doc_rels = package.part_relationships(doc_part)?; + let vba_rel = doc_rels.iter().find(|r| r.rel_type == REL_VBA_PROJECT)?; + + let project_name = resolve(doc_part, &vba_rel.target)?; + let project = package.part(&project_name)?; + + let mut parts = vec![PreservedPart::new( + project_name.as_str(), + package + .content_type(&project_name) + .map(str::to_owned) + .or_else(|| Some(CT_VBA_PROJECT.to_owned())), + project.bytes.clone(), + )]; + + // Optional vbaData.xml, referenced from the project part's own rels. + if let Some(proj_rels) = package.part_relationships(&project_name) + && let Some(data_rel) = proj_rels.iter().find(|r| r.rel_type == REL_WORD_VBA_DATA) + && let Some(data_name) = resolve(&project_name, &data_rel.target) + && let Some(data) = package.part(&data_name) + { + parts.push(PreservedPart::new( + data_name.as_str(), + package + .content_type(&data_name) + .map(str::to_owned) + .or_else(|| Some(CT_VBA_DATA.to_owned())), + data.bytes.clone(), + )); + } + + Some(MacroPayload::new(MacroPayloadKind::OoxmlVba, parts)) +} + +/// Re-emits a preserved VBA payload into a freshly-assembled package. +/// +/// Inserts the preserved parts verbatim, declares their content-type +/// overrides, and regenerates the document→project (and project→data) +/// relationships with a fresh, collision-free relationship id. +/// +/// No-op for a non-VBA payload (defensive; DOCX assembly only calls this for +/// `OoxmlVba`). +pub(crate) fn emit( + pkg: &mut Package, + doc_part: &PartName, + payload: &MacroPayload, + next_rel_id: impl FnMut() -> String, +) -> Result<(), OoxmlError> { + if payload.kind != MacroPayloadKind::OoxmlVba { + return Ok(()); + } + + let mut next_rel_id = next_rel_id; + + // Insert every preserved part and declare its content type. + let mut project_name: Option = None; + let mut data_name: Option = None; + for part in &payload.parts { + let name = PartName::new(part.name.clone()).map_err(OoxmlError::Opc)?; + let media = part + .media_type + .clone() + .unwrap_or_else(|| default_media_type(&part.name).to_owned()); + pkg.content_type_map_mut().add_override(&name, &media); + pkg.set_part(name.clone(), PartData::new(part.bytes.clone(), &media)); + + if part.name.ends_with(SUFFIX_VBA_PROJECT) { + project_name = Some(name); + } else if part.name.ends_with(SUFFIX_VBA_DATA) { + data_name = Some(name); + } + } + + let Some(project_name) = project_name else { + // A payload without a project part is malformed; parts are still + // preserved above, but there is nothing to wire a relationship to. + return Ok(()); + }; + + // document.xml → vbaProject.bin + let project_target = relative_target(doc_part, &project_name); + pkg.part_relationships_mut(doc_part) + .add(Relationship { + id: next_rel_id(), + rel_type: REL_VBA_PROJECT.to_string(), + target: project_target, + target_mode: TargetMode::Internal, + }) + .map_err(OoxmlError::Opc)?; + + // vbaProject.bin → vbaData.xml + if let Some(data_name) = data_name { + let data_target = relative_target(&project_name, &data_name); + pkg.part_relationships_mut(&project_name) + .add(Relationship { + id: "rId1".to_string(), + rel_type: REL_WORD_VBA_DATA.to_string(), + target: data_target, + target_mode: TargetMode::Internal, + }) + .map_err(OoxmlError::Opc)?; + } + + Ok(()) +} + +fn default_media_type(part_name: &str) -> &'static str { + if part_name.ends_with(SUFFIX_VBA_DATA) { + CT_VBA_DATA + } else { + CT_VBA_PROJECT + } +} + +/// Resolves a relationship `target` (relative or absolute) against `base`. +fn resolve(base: &PartName, target: &str) -> Option { + if target.starts_with('/') { + return PartName::new(target).ok(); + } + let base = base.as_str(); + let dir = base.rfind('/').map_or("/", |i| &base[..=i]); + PartName::new(format!("{dir}{target}")).ok() +} + +/// Computes the target path of `to` relative to the directory of `from`. +/// +/// Both are sibling parts under the same directory in every real document +/// (`/word/…`), so this reduces to the file name; if they diverge, the +/// absolute path is used (always valid in an OPC `.rels`). +fn relative_target(from: &PartName, to: &PartName) -> String { + let from = from.as_str(); + let dir = from.rfind('/').map_or("/", |i| &from[..=i]); + to.as_str() + .strip_prefix(dir) + .map_or_else(|| to.as_str().to_owned(), str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_relative_against_word_dir() { + let base = PartName::new("/word/document.xml").unwrap(); + let got = resolve(&base, "vbaProject.bin").unwrap(); + assert_eq!(got.as_str(), "/word/vbaProject.bin"); + } + + #[test] + fn resolve_absolute_target() { + let base = PartName::new("/word/document.xml").unwrap(); + let got = resolve(&base, "/word/vbaProject.bin").unwrap(); + assert_eq!(got.as_str(), "/word/vbaProject.bin"); + } + + #[test] + fn relative_target_is_sibling_file_name() { + let from = PartName::new("/word/document.xml").unwrap(); + let to = PartName::new("/word/vbaProject.bin").unwrap(); + assert_eq!(relative_target(&from, &to), "vbaProject.bin"); + } + + #[test] + fn default_media_types() { + assert_eq!(default_media_type("/word/vbaProject.bin"), CT_VBA_PROJECT); + assert_eq!(default_media_type("/word/vbaData.xml"), CT_VBA_DATA); + } +} diff --git a/loki-ooxml/src/xlsx/export.rs b/loki-ooxml/src/xlsx/export.rs index 05b28a55..fd160c6c 100644 --- a/loki-ooxml/src/xlsx/export.rs +++ b/loki-ooxml/src/xlsx/export.rs @@ -5,6 +5,7 @@ use crate::constants::REL_OFFICE_DOCUMENT; use crate::error::OoxmlError; +use loki_doc_model::io::macros::MacroPayload; use loki_opc::Package; use loki_opc::part::{PartData, PartName}; use loki_opc::relationships::{Relationship, TargetMode}; @@ -12,6 +13,12 @@ use loki_sheet_model::Workbook; use std::collections::HashMap; use std::io::{Seek, Write}; +/// Main-part content type for a normal workbook (`.xlsx`). +const MT_WORKBOOK: &str = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"; +/// Main-part content type for a macro-enabled workbook (`.xlsm`). +const MT_WORKBOOK_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml"; + #[path = "export_xml.rs"] mod xml; @@ -24,8 +31,27 @@ pub struct XlsxExport; impl XlsxExport { /// Exports a workbook model and writes the XLSX ZIP bytes to the writer. - #[allow(clippy::too_many_lines)] pub fn export(workbook: &Workbook, writer: impl Write + Seek) -> Result<(), OoxmlError> { + Self::export_with_macros(workbook, writer, None) + } + + /// Exports a workbook, re-emitting a preserved VBA payload as a + /// macro-enabled workbook (`.xlsm`) when `macros` is `Some` (spec §3.3). + /// + /// When `macros` is `None` the output is a plain `.xlsx` and any macros + /// the workbook once carried are deliberately dropped, matching Office's + /// extension semantics. + #[allow(clippy::too_many_lines)] + pub fn export_with_macros( + workbook: &Workbook, + writer: impl Write + Seek, + macros: Option<&MacroPayload>, + ) -> Result<(), OoxmlError> { + let main_ct = if macros.is_some() { + MT_WORKBOOK_MACRO + } else { + MT_WORKBOOK + }; let mut pkg = Package::new(); // 1. Gather all unique non-default styles used in the workbook @@ -65,10 +91,7 @@ impl XlsxExport { let workbook_xml = generate_workbook_xml(workbook); pkg.set_part( workbook_part.clone(), - PartData::new( - workbook_xml.into_bytes(), - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", - ), + PartData::new(workbook_xml.into_bytes(), main_ct), ); // Styles XML @@ -164,10 +187,7 @@ impl XlsxExport { "application/vnd.openxmlformats-package.relationships+xml", ); ct.add_default("xml", "application/xml"); - ct.add_override( - &workbook_part, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", - ); + ct.add_override(&workbook_part, main_ct); ct.add_override( &styles_part, "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", @@ -187,7 +207,15 @@ impl XlsxExport { ); } - // 7. Write ZIP + // 7. Re-emit preserved VBA payload (spec §3.3). vbaProject.bin is wired + // from the workbook part; the fresh rId sits above the sheet rIds + // (styles=rId1, sharedStrings=rId2, sheets=rId3..). + if let Some(payload) = macros { + let vba_rel_id = format!("rId{}", workbook.sheets.len() + 3); + crate::vba::emit(&mut pkg, &workbook_part, payload, || vba_rel_id.clone())?; + } + + // 8. Write ZIP pkg.write(writer).map_err(OoxmlError::Opc) } } diff --git a/loki-ooxml/src/xlsx/import.rs b/loki-ooxml/src/xlsx/import.rs index f3322768..93e50850 100644 --- a/loki-ooxml/src/xlsx/import.rs +++ b/loki-ooxml/src/xlsx/import.rs @@ -6,6 +6,7 @@ use crate::constants::REL_OFFICE_DOCUMENT; use crate::error::{OoxmlError, OoxmlWarning}; use crate::xml_util::{event_text, local_attr_val, local_name}; +use loki_doc_model::io::macros::MacroPayload; use loki_opc::{Package, PartName}; use loki_sheet_model::{DocumentMeta, Workbook, Worksheet}; use quick_xml::Reader; @@ -31,6 +32,10 @@ pub struct XlsxImportResult { pub workbook: Workbook, /// Non-fatal warnings. pub warnings: Vec, + /// Preserved VBA macro payload (`.xlsm`/`.xltm`), if present. Not + /// executed in Phase 1; retained so a macro-enabled re-export does not + /// strip it (spec §3). + pub macros: Option, } /// Unit struct that implements XLSX spreadsheet import. @@ -38,10 +43,22 @@ pub struct XlsxImport; impl XlsxImport { /// Imports an XLSX file and returns the workbook. + /// + /// Discards warnings and any preserved macro payload; use + /// [`XlsxImport::run`] to retrieve them. pub fn import( reader: impl Read + Seek, - _options: XlsxImportOptions, + options: XlsxImportOptions, ) -> Result { + Self::run(reader, options).map(|r| r.workbook) + } + + /// Imports an XLSX file, returning the workbook plus warnings and any + /// preserved VBA macro payload. + pub fn run( + reader: impl Read + Seek, + _options: XlsxImportOptions, + ) -> Result { let package = Package::open(reader)?; // 1. Locate the workbook (main document part) @@ -118,9 +135,16 @@ impl XlsxImport { sheets.push(Worksheet::new("Sheet1")); } - Ok(Workbook { - meta: DocumentMeta::default(), - sheets, + // Preserve any VBA macro payload (spec §3, Phase 1). + let macros = crate::vba::collect(&package, &workbook_part_name); + + Ok(XlsxImportResult { + workbook: Workbook { + meta: DocumentMeta::default(), + sheets, + }, + warnings: Vec::new(), + macros, }) } } @@ -252,28 +276,3 @@ fn rels_by_type<'a>( .filter(move |r| r.rel_type == trans_owned || r.rel_type == strict_owned) } -// ── Coordinate Conversion Helpers ────────────────────────────────────────── - -fn cell_ref_to_coord(cell_ref: &str) -> Option<(u32, u32)> { - // Allocation-free split of "AB12" into column letters and row digits — - // this runs once per cell on import. The leading letters are single-byte - // ASCII, so `split` always lands on a char boundary; a non-digit tail - // (or a non-ASCII byte) simply fails the row parse, as before. - let bytes = cell_ref.as_bytes(); - let split = bytes - .iter() - .position(|b| !b.is_ascii_alphabetic()) - .unwrap_or(bytes.len()); - if split == 0 || split == bytes.len() { - return None; - } - let mut col: u32 = 0; - for &b in &bytes[..split] { - col = col - .checked_mul(26)? - .checked_add(u32::from(b.to_ascii_uppercase() - b'A') + 1)?; - } - let col = col.checked_sub(1)?; - let row = cell_ref[split..].parse::().ok()?.checked_sub(1)?; - Some((row, col)) -} diff --git a/loki-ooxml/src/xlsx/import_worksheet.rs b/loki-ooxml/src/xlsx/import_worksheet.rs index 38e150d7..20d68640 100644 --- a/loki-ooxml/src/xlsx/import_worksheet.rs +++ b/loki-ooxml/src/xlsx/import_worksheet.rs @@ -4,13 +4,13 @@ //! Worksheet (`xl/worksheets/sheetN.xml`) parsing for the XLSX importer //! (split from `import.rs` for the 300-line ceiling): reads cells (values via //! the shared-strings table, formulas, per-cell style index) and column -//! widths into a `Worksheet`. Column-width conversion and the A1 cell-ref -//! decoder stay in `import.rs`. +//! widths into a `Worksheet`. Column-width conversion stays in `import.rs`; +//! the A1 cell-ref decoder lives here (its only caller). use quick_xml::Reader; use quick_xml::events::Event; -use super::{cell_ref_to_coord, xlsx_char_width_to_pt}; +use super::xlsx_char_width_to_pt; use crate::error::OoxmlError; use crate::xml_util::{event_text, local_attr_val, local_attr_vals, local_name}; use loki_sheet_model::{Cell, CellStyle, Worksheet}; @@ -174,3 +174,28 @@ pub(super) fn parse_worksheet( Ok(worksheet) } + +/// Decodes an A1-style cell reference (`"AB12"`) into zero-based `(row, col)`. +fn cell_ref_to_coord(cell_ref: &str) -> Option<(u32, u32)> { + // Allocation-free split of "AB12" into column letters and row digits — + // this runs once per cell on import. The leading letters are single-byte + // ASCII, so `split` always lands on a char boundary; a non-digit tail + // (or a non-ASCII byte) simply fails the row parse, as before. + let bytes = cell_ref.as_bytes(); + let split = bytes + .iter() + .position(|b| !b.is_ascii_alphabetic()) + .unwrap_or(bytes.len()); + if split == 0 || split == bytes.len() { + return None; + } + let mut col: u32 = 0; + for &b in &bytes[..split] { + col = col + .checked_mul(26)? + .checked_add(u32::from(b.to_ascii_uppercase() - b'A') + 1)?; + } + let col = col.checked_sub(1)?; + let row = cell_ref[split..].parse::().ok()?.checked_sub(1)?; + Some((row, col)) +} diff --git a/loki-ooxml/src/xlsx/mod.rs b/loki-ooxml/src/xlsx/mod.rs index 1c293dc8..5aa47471 100644 --- a/loki-ooxml/src/xlsx/mod.rs +++ b/loki-ooxml/src/xlsx/mod.rs @@ -5,3 +5,7 @@ pub mod export; pub mod import; + +#[cfg(test)] +#[path = "vba_tests.rs"] +mod vba_tests; diff --git a/loki-ooxml/src/xlsx/vba_tests.rs b/loki-ooxml/src/xlsx/vba_tests.rs new file mode 100644 index 00000000..66d46674 --- /dev/null +++ b/loki-ooxml/src/xlsx/vba_tests.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::io::Cursor; + +use loki_doc_model::io::macros::MacroPayloadKind; +use loki_opc::Package; +use loki_opc::part::{PartData, PartName}; +use loki_opc::relationships::{Relationship, TargetMode}; + +use crate::vba::REL_VBA_PROJECT; +use crate::xlsx::export::XlsxExport; +use crate::xlsx::import::{XlsxImport, XlsxImportOptions}; + +const FAKE_VBA: &[u8] = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1FAKE-EXCEL-VBA-PROJECT"; + +/// Builds a minimal in-memory `.xlsm`: one worksheet + a `xl/vbaProject.bin` +/// wired from the workbook part via the standard MS `vbaProject` rel. +fn build_xlsm() -> Vec { + let mut pkg = Package::new(); + + let wb_part = PartName::new("/xl/workbook.xml").unwrap(); + let sheet_part = PartName::new("/xl/worksheets/sheet1.xml").unwrap(); + let vba_part = PartName::new("/xl/vbaProject.bin").unwrap(); + + let wb_xml = br#" + +"#; + let sheet_xml = br#" + +Hi"#; + + pkg.set_part( + wb_part.clone(), + PartData::new( + wb_xml.to_vec(), + "application/vnd.ms-excel.sheet.macroEnabled.main+xml", + ), + ); + pkg.set_part( + sheet_part.clone(), + PartData::new( + sheet_xml.to_vec(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + ), + ); + pkg.set_part( + vba_part.clone(), + PartData::new(FAKE_VBA.to_vec(), "application/vnd.ms-office.vbaProject"), + ); + + let ct = pkg.content_type_map_mut(); + ct.add_default( + "rels", + "application/vnd.openxmlformats-package.relationships+xml", + ); + ct.add_default("xml", "application/xml"); + ct.add_override( + &wb_part, + "application/vnd.ms-excel.sheet.macroEnabled.main+xml", + ); + ct.add_override( + &sheet_part, + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", + ); + ct.add_override(&vba_part, "application/vnd.ms-office.vbaProject"); + + pkg.relationships_mut() + .add(Relationship { + id: "rId1".into(), + rel_type: + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" + .into(), + target: "xl/workbook.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + + let wb_rels = pkg.part_relationships_mut(&wb_part); + wb_rels + .add(Relationship { + id: "rId1".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet".into(), + target: "worksheets/sheet1.xml".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + wb_rels + .add(Relationship { + id: "rId2".into(), + rel_type: REL_VBA_PROJECT.into(), + target: "vbaProject.bin".into(), + target_mode: TargetMode::Internal, + }) + .unwrap(); + + let mut buf = Cursor::new(Vec::new()); + pkg.write(&mut buf).unwrap(); + buf.into_inner() +} + +#[test] +fn import_collects_xlsm_vba_payload() { + let result = XlsxImport::run(Cursor::new(build_xlsm()), XlsxImportOptions::default()) + .expect("import"); + let macros = result.macros.expect("macro payload preserved"); + assert_eq!(macros.kind, MacroPayloadKind::OoxmlVba); + let project = macros + .parts + .iter() + .find(|p| p.name.ends_with("vbaProject.bin")) + .expect("vbaProject.bin part"); + assert_eq!(project.bytes, FAKE_VBA); +} + +#[test] +fn macro_enabled_export_preserves_project_bytes() { + let imported = XlsxImport::run(Cursor::new(build_xlsm()), XlsxImportOptions::default()) + .expect("import"); + let macros = imported.macros.clone().expect("macros present"); + + let mut out = Cursor::new(Vec::new()); + XlsxExport::export_with_macros(&imported.workbook, &mut out, Some(¯os)).expect("export"); + + let reimported = XlsxImport::run(Cursor::new(out.into_inner()), XlsxImportOptions::default()) + .expect("reimport"); + let re_macros = reimported.macros.expect("macros survive round-trip"); + let project = re_macros + .parts + .iter() + .find(|p| p.name.ends_with("vbaProject.bin")) + .expect("vbaProject.bin"); + assert_eq!(project.bytes, FAKE_VBA); + assert_eq!(macros.payload_hash(), re_macros.payload_hash()); +} + +#[test] +fn plain_export_strips_macros() { + let imported = XlsxImport::run(Cursor::new(build_xlsm()), XlsxImportOptions::default()) + .expect("import"); + + let mut out = Cursor::new(Vec::new()); + XlsxExport::export(&imported.workbook, &mut out).expect("export"); + + let reimported = XlsxImport::run(Cursor::new(out.into_inner()), XlsxImportOptions::default()) + .expect("reimport"); + assert!( + reimported.macros.is_none(), + "a plain .xlsx save must drop the VBA payload" + ); +} From 412b165bee39e5958d2e138a096771fe015bfa9b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 08:29:17 +0000 Subject: [PATCH 03/71] Macro Phase 1: preserve ODF Basic/Scripts libraries through round-trip Teaches OdfPackage to collect the Basic/ and Scripts/ subtrees (manifest- driven, preserving each entry's declared media type and directory entries) into a MacroPayload (kind OdfBasic) on document.source.macros. ODT export and ODS export re-emit the libraries verbatim plus their manifest file-entries via a shared script_write helper. Scripts are never parsed or executed. - loki-odf: package_scripts collector; OdfPackage.macros; script_write shared re-emit helper; OdtImporter/OdtExport wired; OdsImport::run + OdsImportResult.macros + OdsExport::export_with_macros - Round-trip test: byte-identical StarBasic module + stable payload hash Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DQSgJrDikHzN5tCqVzsfgB --- loki-odf/src/lib.rs | 1 + loki-odf/src/ods/export.rs | 33 +++++- loki-odf/src/ods/import.rs | 27 ++++- loki-odf/src/odt/export.rs | 22 +++- loki-odf/src/odt/import.rs | 7 +- loki-odf/src/package.rs | 28 +++-- loki-odf/src/package_scripts.rs | 160 +++++++++++++++++++++++++++++ loki-odf/src/script_write.rs | 101 ++++++++++++++++++ loki-odf/tests/macro_round_trip.rs | 124 ++++++++++++++++++++++ 9 files changed, 482 insertions(+), 21 deletions(-) create mode 100644 loki-odf/src/package_scripts.rs create mode 100644 loki-odf/src/script_write.rs create mode 100644 loki-odf/tests/macro_round_trip.rs diff --git a/loki-odf/src/lib.rs b/loki-odf/src/lib.rs index 587faa84..9fb55684 100644 --- a/loki-odf/src/lib.rs +++ b/loki-odf/src/lib.rs @@ -58,6 +58,7 @@ pub(crate) mod limits; pub mod ods; pub mod odt; pub mod package; +pub(crate) mod script_write; pub mod version; pub(crate) mod xml_util; diff --git a/loki-odf/src/ods/export.rs b/loki-odf/src/ods/export.rs index 6eba366d..8bb7098a 100644 --- a/loki-odf/src/ods/export.rs +++ b/loki-odf/src/ods/export.rs @@ -3,6 +3,7 @@ //! ODS exporter. +use loki_doc_model::io::macros::MacroPayload; use loki_sheet_model::Workbook; use std::io::{Seek, Write}; use zip::{CompressionMethod, ZipWriter, write::FileOptions}; @@ -25,6 +26,17 @@ pub struct OdsExport; impl OdsExport { /// Export a [`Workbook`] to an ODS writer. pub fn export(workbook: &Workbook, writer: impl Write + Seek) -> Result<(), OdfError> { + Self::export_with_macros(workbook, writer, None) + } + + /// Export a [`Workbook`], re-emitting a preserved StarBasic/script payload + /// when `macros` is `Some` (spec §3.3). `None` drops any prior macros. + pub fn export_with_macros( + workbook: &Workbook, + writer: impl Write + Seek, + macros: Option<&MacroPayload>, + ) -> Result<(), OdfError> { + let scripts = crate::script_write::odf_script_payload(macros); let mut zip = ZipWriter::new(writer); // 1. mimetype (stored, uncompressed) @@ -36,7 +48,7 @@ impl OdsExport { // 2. META-INF/manifest.xml zip.start_file(ENTRY_MANIFEST, deflated)?; - zip.write_all(generate_manifest().as_bytes())?; + zip.write_all(generate_manifest(scripts).as_bytes())?; // 3. styles.xml zip.start_file(ENTRY_STYLES, deflated)?; @@ -46,20 +58,31 @@ impl OdsExport { zip.start_file(ENTRY_CONTENT, deflated)?; zip.write_all(generate_content(workbook).as_bytes())?; + // 5. preserved macro/script libraries (Basic/, Scripts/), verbatim. + if let Some(payload) = scripts { + crate::script_write::write_script_parts(&mut zip, payload)?; + } + zip.finish()?; Ok(()) } } -fn generate_manifest() -> String { - r#" +fn generate_manifest(scripts: Option<&MacroPayload>) -> String { + let mut m = String::from( + r#" - -"#.to_string() +"#, + ); + if let Some(payload) = scripts { + m.push_str(&crate::script_write::script_manifest_entries(payload)); + } + m.push_str("\n"); + m } fn generate_styles() -> String { diff --git a/loki-odf/src/ods/import.rs b/loki-odf/src/ods/import.rs index 5749a600..04e7d83a 100644 --- a/loki-odf/src/ods/import.rs +++ b/loki-odf/src/ods/import.rs @@ -3,6 +3,7 @@ //! ODS importer. +use loki_doc_model::io::macros::MacroPayload; use loki_sheet_model::{Cell, DocumentMeta, Workbook, Worksheet}; use quick_xml::Reader; use quick_xml::events::Event; @@ -28,6 +29,10 @@ pub struct OdsImportOptions {} pub struct OdsImportResult { /// The imported workbook model. pub workbook: Workbook, + /// Preserved StarBasic / script payload, if present. Not executed in + /// Phase 1; retained so a macro-carrying re-export does not strip it + /// (spec §3). + pub macros: Option, } /// Unit struct that implements ODS spreadsheet import. @@ -35,10 +40,21 @@ pub struct OdsImport; impl OdsImport { /// Imports an ODS file and returns the workbook. + /// + /// Discards any preserved macro payload; use [`OdsImport::run`] to keep it. pub fn import( reader: impl Read + Seek, - _options: OdsImportOptions, + options: OdsImportOptions, ) -> Result { + Self::run(reader, options).map(|r| r.workbook) + } + + /// Imports an ODS file, returning the workbook plus any preserved macro + /// payload. + pub fn run( + reader: impl Read + Seek, + _options: OdsImportOptions, + ) -> Result { let package = OdfPackage::open(reader)?; // 1. Parse ODS styles @@ -254,9 +270,12 @@ impl OdsImport { sheets.push(Worksheet::new("Sheet1")); } - Ok(Workbook { - meta: DocumentMeta::default(), - sheets, + Ok(OdsImportResult { + workbook: Workbook { + meta: DocumentMeta::default(), + sheets, + }, + macros: package.macros, }) } } diff --git a/loki-odf/src/odt/export.rs b/loki-odf/src/odt/export.rs index 55ee453a..be7d6731 100644 --- a/loki-odf/src/odt/export.rs +++ b/loki-odf/src/odt/export.rs @@ -11,6 +11,7 @@ use std::io::{Seek, Write}; use loki_doc_model::document::Document; use loki_doc_model::io::DocumentExport; +use loki_doc_model::io::macros::MacroPayload; use zip::{CompressionMethod, ZipWriter, write::FileOptions}; use crate::constants::{ @@ -38,6 +39,9 @@ impl DocumentExport for OdtExport { fn export(doc: &Document, writer: impl Write + Seek, _options: Self::Options) -> OdfResult<()> { let content = content_xml(doc); let styles = styles_xml(doc); + let scripts = crate::script_write::odf_script_payload( + doc.source.as_ref().and_then(|s| s.macros.as_ref()), + ); let mut zip = ZipWriter::new(writer); @@ -52,7 +56,7 @@ impl DocumentExport for OdtExport { // the body and the master-page header/footer, plus any embedded // formula objects). zip.start_file(ENTRY_MANIFEST, deflated)?; - zip.write_all(manifest(&content.media, &styles.media, &content.objects).as_bytes())?; + zip.write_all(manifest(&content.media, &styles.media, &content.objects, scripts).as_bytes())?; // 3. the three XML parts. zip.start_file(ENTRY_CONTENT, deflated)?; @@ -77,6 +81,11 @@ impl DocumentExport for OdtExport { zip.write_all(obj.content_xml.as_bytes())?; } + // 6. preserved macro/script libraries (Basic/, Scripts/), verbatim. + if let Some(payload) = scripts { + crate::script_write::write_script_parts(&mut zip, payload)?; + } + zip.finish()?; Ok(()) } @@ -85,7 +94,12 @@ impl DocumentExport for OdtExport { /// Builds `META-INF/manifest.xml`, listing the fixed parts, every image /// (from the body and the master-page header/footer), and every embedded /// formula object sub-document. -fn manifest(body_media: &[MediaPart], styles_media: &[MediaPart], objects: &[MathPart]) -> String { +fn manifest( + body_media: &[MediaPart], + styles_media: &[MediaPart], + objects: &[MathPart], + scripts: Option<&MacroPayload>, +) -> String { let mut m = String::from(concat!( "\n", ""); m } diff --git a/loki-odf/src/odt/import.rs b/loki-odf/src/odt/import.rs index 88c0f668..8d97504c 100644 --- a/loki-odf/src/odt/import.rs +++ b/loki-odf/src/odt/import.rs @@ -201,8 +201,11 @@ impl OdtImporter { warnings.append(&mut mapper_warnings); // Set provenance (version detected above overrides any version the - // mapper may have computed from the body XML). - document.source = Some(DocumentSource::new("odf").with_version(source_version.as_str())); + // mapper may have computed from the body XML). Preserve any macro/script + // libraries so a round-trip does not strip them (spec §3, Phase 1). + let mut source = DocumentSource::new("odf").with_version(source_version.as_str()); + source.macros = package.macros; + document.source = Some(source); Ok(OdtImportResult { document, diff --git a/loki-odf/src/package.rs b/loki-odf/src/package.rs index ced55bf6..607e61db 100644 --- a/loki-odf/src/package.rs +++ b/loki-odf/src/package.rs @@ -25,6 +25,11 @@ use crate::version::OdfVersion; mod read; use read::{collect_images, collect_objects, read_entry, validate_mimetype}; +#[path = "package_scripts.rs"] +mod scripts; +use loki_doc_model::io::macros::MacroPayload; +use scripts::collect_scripts; + /// Contents of an opened ODF package. /// /// Holds the raw bytes of each standard part so that callers can parse them @@ -69,6 +74,11 @@ pub struct OdfPackage { /// An absent attribute is valid for ODF 1.1 documents; in that case the /// version is assumed to be [`OdfVersion::V1_1`]. pub version_was_absent: bool, + + /// Preserved StarBasic / script-library payload, if the package declared + /// one (`Basic/` and/or `Scripts/`). Not executed in Phase 1; retained so + /// export can re-emit it verbatim (spec §3). + pub macros: Option, } impl OdfPackage { @@ -100,14 +110,12 @@ impl OdfPackage { // ── 1. Validate mimetype entry ───────────────────────────────────── let mimetype = validate_mimetype(&mut archive, &mut total_decompressed)?; - // ── 2. Require META-INF/manifest.xml ────────────────────────────── - { - let _ = archive - .by_name(ENTRY_MANIFEST) - .map_err(|_| OdfError::MissingPart { - part: ENTRY_MANIFEST.into(), - })?; - } + // ── 2. Require META-INF/manifest.xml (and keep its bytes for the + // script-library collector in step 6c) ──────────────────────── + let manifest = read_entry(&mut archive, ENTRY_MANIFEST, &mut total_decompressed)? + .ok_or_else(|| OdfError::MissingPart { + part: ENTRY_MANIFEST.into(), + })?; // ── 3. Read content.xml (required) ──────────────────────────────── let content = read_entry(&mut archive, ENTRY_CONTENT, &mut total_decompressed)? @@ -129,6 +137,9 @@ impl OdfPackage { // ── 6b. Collect embedded object sub-documents (e.g. formulas) ───── let objects = collect_objects(&mut archive, &mut total_decompressed)?; + // ── 6c. Preserve macro/script libraries (Basic/, Scripts/) ──────── + let macros = collect_scripts(&mut archive, &manifest, &mut total_decompressed)?; + // ── 7. Detect version from content.xml ──────────────────────────── let (version, version_was_absent) = Self::detect_version(&content)?; @@ -142,6 +153,7 @@ impl OdfPackage { images, objects, version_was_absent, + macros, }) } diff --git a/loki-odf/src/package_scripts.rs b/loki-odf/src/package_scripts.rs new file mode 100644 index 00000000..e371037e --- /dev/null +++ b/loki-odf/src/package_scripts.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! StarBasic / script-library preservation for ODF packages (spec §3, Phase 1). +//! +//! ODF stores macros as a `Basic/` library subtree (StarBasic) and/or a +//! `Scripts/` subtree (other script providers), each file declared in +//! `META-INF/manifest.xml`. Loki does **not** execute these in Phase 1; it +//! preserves them byte-for-byte so a load→edit→save cycle no longer silently +//! strips them (the pre-Phase-1 reader extracted only a fixed part list). +//! +//! Collection is **manifest-driven** so each preserved entry keeps its exact +//! declared media type and so directory entries (which have no ZIP payload) +//! round-trip too. File bytes come from the ZIP. The `` event +//! bindings inside `content.xml` are a separate concern (that part is +//! regenerated on export); binding-level round-trip is deferred to the +//! execution phases. + +use std::io::{Read, Seek}; + +use loki_doc_model::io::macros::{MacroPayload, MacroPayloadKind, PreservedPart}; +use quick_xml::Reader; +use quick_xml::events::Event; +use zip::ZipArchive; + +use crate::error::OdfResult; +use crate::limits::read_entry_capped; + +/// Path prefixes that hold macro/script libraries in an ODF package. +const SCRIPT_PREFIXES: [&str; 2] = ["Basic/", "Scripts/"]; + +/// Returns `true` if `path` lives under a script-library subtree. +fn is_script_path(path: &str) -> bool { + SCRIPT_PREFIXES.iter().any(|p| path.starts_with(p)) +} + +/// Collects the ODF script payload, if any, driven by the manifest. +/// +/// `manifest` is the raw `META-INF/manifest.xml` bytes (already read by the +/// package opener). Returns `None` when the package declares no script +/// libraries. +pub(super) fn collect_scripts( + archive: &mut ZipArchive, + manifest: &[u8], + total_decompressed: &mut u64, +) -> OdfResult> { + let declared = parse_manifest_scripts(manifest); + if declared.is_empty() { + return Ok(None); + } + + let mut parts = Vec::with_capacity(declared.len()); + for (path, media_type) in declared { + if path.ends_with('/') { + // Directory entry: manifest-only, no ZIP payload. + parts.push(PreservedPart::new(path, Some(media_type), Vec::new())); + continue; + } + // File entry: read its bytes verbatim from the ZIP. + if let Ok(mut entry) = archive.by_name(&path) { + let bytes = read_entry_capped(&mut entry, &path, total_decompressed)?; + parts.push(PreservedPart::new(path, Some(media_type), bytes)); + } + // A manifest entry with no matching ZIP file is malformed input; skip it + // rather than fail — the rest of the payload is still worth preserving. + } + + if parts.iter().all(|p| p.bytes.is_empty()) { + // Only directory entries and no actual script files: nothing to keep. + return Ok(None); + } + + Ok(Some(MacroPayload::new(MacroPayloadKind::OdfBasic, parts))) +} + +/// Parses `manifest` and returns `(full_path, media_type)` for every +/// `` under a script subtree, preserving declaration +/// order. +fn parse_manifest_scripts(manifest: &[u8]) -> Vec<(String, String)> { + let mut reader = Reader::from_reader(manifest); + reader.config_mut().trim_text(false); + + let mut out = Vec::new(); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e) | Event::Empty(ref e)) => { + if local(e.local_name().into_inner()) == b"file-entry" { + let path = attr(e, b"full-path"); + if let Some(path) = path.filter(|p| is_script_path(p)) { + let media = attr(e, b"media-type").unwrap_or_default(); + out.push((path, media)); + } + } + buf.clear(); + } + Ok(Event::Eof) | Err(_) => break, + _ => buf.clear(), + } + } + out +} + +/// Reads an attribute's value by local name (namespace-prefix-insensitive). +fn attr(e: &quick_xml::events::BytesStart<'_>, local_name: &[u8]) -> Option { + e.attributes().flatten().find_map(|a| { + if local(a.key.local_name().into_inner()) == local_name { + String::from_utf8(a.value.into_owned()).ok() + } else { + None + } + }) +} + +/// Local part (after the last `:`) of a qualified name. +fn local(qname: &[u8]) -> &[u8] { + qname + .iter() + .rposition(|&b| b == b':') + .map_or(qname, |pos| &qname[pos + 1..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MANIFEST: &[u8] = br#" + + + + + + + +"#; + + #[test] + fn parses_only_script_entries() { + let got = parse_manifest_scripts(MANIFEST); + let paths: Vec<&str> = got.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!( + paths, + vec![ + "Basic/", + "Basic/Standard/", + "Basic/Standard/Module1.xml", + "Basic/script-lc.xml", + ] + ); + // content.xml and the root entry are excluded. + assert!(!paths.contains(&"content.xml")); + } + + #[test] + fn is_script_path_matches_both_subtrees() { + assert!(is_script_path("Basic/Standard/Module1.xml")); + assert!(is_script_path("Scripts/python/foo.py")); + assert!(!is_script_path("Pictures/img.png")); + } +} diff --git a/loki-odf/src/script_write.rs b/loki-odf/src/script_write.rs new file mode 100644 index 00000000..24ad0afd --- /dev/null +++ b/loki-odf/src/script_write.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Re-emission of preserved ODF macro/script libraries on export (spec §3.3). +//! +//! Shared by ODT and ODS export: given a preserved [`MacroPayload`] of kind +//! [`MacroPayloadKind::OdfBasic`], this emits the manifest `` lines +//! and writes each script file back into the ZIP verbatim. Directory entries +//! (empty-byte parts whose path ends in `/`) contribute a manifest line only. +//! Loki does not parse or execute the scripts — this is byte preservation. + +use std::io::{Seek, Write}; + +use loki_doc_model::io::macros::{MacroPayload, MacroPayloadKind}; +use zip::{CompressionMethod, ZipWriter, write::FileOptions}; + +use crate::error::OdfResult; + +/// Extracts the preserved ODF script payload from a document's provenance, if +/// it carries one of the right kind. +#[must_use] +pub(crate) fn odf_script_payload(macros: Option<&MacroPayload>) -> Option<&MacroPayload> { + macros.filter(|m| m.kind == MacroPayloadKind::OdfBasic && !m.is_empty()) +} + +/// Builds the `` lines for a preserved script payload, +/// preserving each entry's declared media type and path. +#[must_use] +pub(crate) fn script_manifest_entries(payload: &MacroPayload) -> String { + let mut m = String::new(); + for part in &payload.parts { + let media = part.media_type.as_deref().unwrap_or(""); + m.push_str(&format!( + "", + escape(&part.name), + escape(media), + )); + } + m +} + +/// Writes each preserved script *file* (non-empty payload) into the ZIP. +/// Directory-only entries carry no bytes and are represented in the manifest +/// alone, so they are skipped here. +pub(crate) fn write_script_parts( + zip: &mut ZipWriter, + payload: &MacroPayload, +) -> OdfResult<()> { + let stored = FileOptions::<()>::default().compression_method(CompressionMethod::Deflated); + for part in &payload.parts { + if part.name.ends_with('/') || part.bytes.is_empty() { + continue; + } + zip.start_file(&part.name, stored)?; + zip.write_all(&part.bytes)?; + } + Ok(()) +} + +/// Minimal XML-attribute escaping for manifest paths/media types. +fn escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + use loki_doc_model::io::macros::PreservedPart; + + fn payload() -> MacroPayload { + MacroPayload::new( + MacroPayloadKind::OdfBasic, + vec![ + PreservedPart::new("Basic/", Some(String::new()), Vec::new()), + PreservedPart::new( + "Basic/Standard/Module1.xml", + Some("text/xml".into()), + b"