Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/docx-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export {
// Re-export the LibreOffice accept/reject oracle (gated reference voter; callers skip when
// `resolveSoffice()` is null or `probeSofficeUsable()` is false — the binary can exist yet
// abort on launch under a restricted shell). odf-core's round-trip tests drive it with `.odt` jobs.
export { resolveSoffice, probeSofficeUsable, runLibreOfficeOracle, type OracleJob } from './integration/libreoffice-oracle.js';
export { resolveSoffice, probeSofficeUsable, runLibreOfficeOracle, acquireGlobalSofficeLock, type OracleJob } from './integration/libreoffice-oracle.js';

// Synthetic-DOCX fixture builders re-exported for downstream packages' test suites
// (odf-core's DOCX→ODT conversion tests build their inputs with these). They live under
Expand Down
94 changes: 94 additions & 0 deletions packages/docx-core/src/integration/libreoffice-oracle-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Cross-process LibreOffice lock — pure unit coverage.
*
* The lock (`acquireGlobalSofficeLock`) serializes every soffice launch in the repo on a
* single machine-wide lockfile so parallel vitest workers, sibling agent sessions, and a
* human never spawn concurrent headless LibreOffice instances (the amplification vector
* behind issue #627). These cases exercise the lock's branches WITHOUT launching soffice,
* using a temp lockfile: exclusive acquisition, contention against a held lock, and stealing
* a stale lock whose recorded holder PID is dead.
*
* @see https://github.com/UseJunior/safe-docx/issues/627
*/
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect } from 'vitest';
import { acquireGlobalSofficeLock } from './libreoffice-oracle.js';
import { testAllure, type AllureBddContext } from '../testing/allure-test.js';

const TEST_FEATURE = 'LibreOffice Oracle Cross-Process Lock';
const test = testAllure.epic('Document Comparison').withLabels({ feature: TEST_FEATURE });

describe('acquireGlobalSofficeLock', () => {
let dir: string;
let lockPath: string;

beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), 'lo-lock-test-'));
lockPath = path.join(dir, 'soffice.lock');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

test('acquires exclusively and releases idempotently', async ({ given, when, then, and }: AllureBddContext) => {
let release: () => void;
await given('a free lock path', () => {
expect(existsSync(lockPath)).toBe(false);
});
await when('the lock is acquired', async () => {
release = await acquireGlobalSofficeLock(5_000, { lockPath, pollMs: 20 });
});
await then('the lockfile exists and records this process PID', () => {
expect(existsSync(lockPath)).toBe(true);
const holder = JSON.parse(readFileSync(lockPath, 'utf8')) as { pid: number };
expect(holder.pid).toBe(process.pid);
});
await and('releasing removes it and a second release is a no-op', () => {
release();
expect(existsSync(lockPath)).toBe(false);
expect(() => release()).not.toThrow();
});
});

test('a second waiter blocks until the holder releases', async ({ given, when, then }: AllureBddContext) => {
let firstRelease: () => void;
let acquiredSecond = false;
await given('the lock is already held', async () => {
firstRelease = await acquireGlobalSofficeLock(5_000, { lockPath, pollMs: 20 });
});
await when('a second acquisition is attempted while held, then the holder releases', async () => {
const pending = acquireGlobalSofficeLock(5_000, { lockPath, pollMs: 20 }).then((r) => {
acquiredSecond = true;
return r;
});
// Give the waiter time to spin at least once without the lock.
await new Promise((r) => setTimeout(r, 120));
expect(acquiredSecond).toBe(false);
firstRelease();
const secondRelease = await pending;
secondRelease();
});
await then('the second acquisition succeeded only after release', () => {
expect(acquiredSecond).toBe(true);
});
});

test('steals a stale lock whose recorded holder PID is dead', async ({ given, when, then }: AllureBddContext) => {
await given('a lockfile owned by a non-existent PID', () => {
// PID 0x7fffffff is not a live process; process.kill(pid, 0) throws ESRCH.
writeFileSync(lockPath, JSON.stringify({ pid: 0x7fffffff, at: new Date().toISOString() }));
expect(existsSync(lockPath)).toBe(true);
});
let release: () => void;
await when('a new acquisition runs', async () => {
release = await acquireGlobalSofficeLock(5_000, { lockPath, pollMs: 20 });
});
await then('it steals the stale lock and takes ownership', () => {
const holder = JSON.parse(readFileSync(lockPath, 'utf8')) as { pid: number };
expect(holder.pid).toBe(process.pid);
release();
});
});
});
92 changes: 91 additions & 1 deletion packages/docx-core/src/integration/libreoffice-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@
* CI does not install LibreOffice, so the oracle voter is a local developer check.
*/
import { execFile } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
closeSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
writeSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
Expand Down Expand Up @@ -66,6 +77,81 @@ async function settleProfile(profile: string, timeoutMs = 1_500): Promise<void>
}
}

/**
* Cross-process LibreOffice mutex.
*
* The in-process batching above already guarantees ONE headless launch per oracle batch, but
* nothing coordinated ACROSS processes: parallel vitest workers, sibling agent sessions, and a
* human running the oracle simultaneously each spawned their own soffice. LibreOffice's
* single-instance forwarding makes concurrent launches with distinct profiles merely expensive,
* but concurrent launches are also the amplification vector for the macOS headless startup
* crashes tracked in issue #627 — so all oracle launches in this repo serialize on one
* machine-wide lockfile.
*
* Protocol: exclusive-create (`wx`) a JSON lockfile in the OS temp dir. Holder records its PID;
* waiters poll, stealing the lock only when the recorded PID is dead or the file is older than
* `STALE_LOCK_MS` (a SIGKILLed holder cannot clean up). The steal itself loops back to the
* exclusive create, so exactly one contender wins the recreated file.
*
* @see https://github.com/UseJunior/safe-docx/issues/627
*/
const GLOBAL_SOFFICE_LOCK = path.join(os.tmpdir(), 'safe-docx-soffice-global.lock');
const STALE_LOCK_MS = 10 * 60_000;

/**
* Acquire the cross-process LibreOffice mutex, returning an idempotent release function.
* `lockPath`/`pollMs` are parameterized for unit testing; production callers use the
* defaults (the machine-wide lockfile). Exported so a unit test can exercise the
* exclusive-acquire, wait-for-release, and stale-steal branches without launching soffice.
*/
export async function acquireGlobalSofficeLock(
timeoutMs = 300_000,
{ lockPath = GLOBAL_SOFFICE_LOCK, pollMs = 200 }: { lockPath?: string; pollMs?: number } = {},
): Promise<() => void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const fd = openSync(lockPath, 'wx');
writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
closeSync(fd);
let released = false;
return () => {
if (released) return;
released = true;
try { rmSync(lockPath, { force: true }); } catch { /* best effort */ }
};
} catch {
let stale = false;
try {
const ageMs = Date.now() - statSync(lockPath).mtimeMs;
const holder = JSON.parse(readFileSync(lockPath, 'utf8')) as { pid?: number };
const holderAlive = typeof holder.pid === 'number' && (() => {
try { process.kill(holder.pid!, 0); return true; } catch { return false; }
})();
stale = !holderAlive || ageMs > STALE_LOCK_MS;
} catch (statErr) {
// Vanished between attempts (fine, retry) or unreadable content (steal after grace).
stale = existsSync(lockPath)
? Date.now() - statSync(lockPath).mtimeMs > 10_000
: false;
void statErr;
}
if (stale) {
try { rmSync(lockPath, { force: true }); } catch { /* racer removed it */ }
continue;
}
if (Date.now() > deadline) {
throw new Error(
`Timed out after ${timeoutMs}ms waiting for the cross-process LibreOffice lock at ` +
`${lockPath}; another oracle run appears to be live. Delete the lockfile ` +
'only if you are sure no soffice-driving process is running.',
);
}
await sleep(pollMs + Math.floor(Math.random() * pollMs));
}
}
}

/** Resolve a LibreOffice binary, or null if none is available (callers skip the oracle). */
export function resolveSoffice(): string | null {
const candidates = [
Expand Down Expand Up @@ -97,6 +183,7 @@ export function probeSofficeUsable(soffice: string): Promise<boolean> {
let result = probeResults.get(soffice);
if (!result) {
result = (async () => {
const releaseLock = await acquireGlobalSofficeLock();
const work = mkdtempSync(path.join(os.tmpdir(), 'lo-probe-'));
try {
const inPath = path.join(work, 'probe-input.txt');
Expand All @@ -119,6 +206,7 @@ export function probeSofficeUsable(soffice: string): Promise<boolean> {
);
return existsSync(path.join(outDir, 'probe-input.txt'));
} finally {
releaseLock();
rmSync(work, { recursive: true, force: true });
}
})();
Expand Down Expand Up @@ -256,6 +344,7 @@ export async function runLibreOfficeOracle(jobs: OracleJob[], soffice = resolveS
if (!soffice) throw new Error('runLibreOfficeOracle: no soffice binary (call resolveSoffice() and skip)');
if (jobs.length === 0) return [];

const releaseLock = await acquireGlobalSofficeLock();
const work = mkdtempSync(path.join(os.tmpdir(), 'lo-oracle-'));
const profile = path.join(work, 'profile');
const userDir = path.join(profile, 'user');
Expand Down Expand Up @@ -342,6 +431,7 @@ export async function runLibreOfficeOracle(jobs: OracleJob[], soffice = resolveS
return extractDocumentXml(readFileSync(p));
}));
} finally {
releaseLock();
if (!keepWork) rmSync(work, { recursive: true, force: true });
}
}
Expand Down
131 changes: 131 additions & 0 deletions scripts/corpus/INVESTIGATION_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Corpus differential/fuzz testing — investigation report

Privacy-safe summary of a one-session corpus-scale differential and fuzz run against
safe-docx. Nothing here auto-merged; this PR is a draft. No document bytes, and no private
or customer identifiers/paths/hashes/text, appear in this repo or report.

## Licensing determination (gate — completed before any fetch)

See `scripts/corpus/README.md` for the per-source table. Determination:
- **Redistributable (full use):** open-agreements (CC-BY-4.0), docx-platform-tests
(Apache-2.0), dotnet/Open-XML-SDK (MIT).
- **Local testing only (hash + URL + derived flags; no bytes committed):** SuperDoc
docx-corpus (ODC-BY covers the database, not the underlying Common-Crawl documents) and
the LibreOffice docx-fuzzer seed corpus (MPL covers LibreOffice source, not the scraped
attachment documents). SuperDoc's MIT-vs-ODC-BY license conflict was resolved
conservatively toward ODC-BY.

## Corpus acquired

520 manifest entries, SHA-256-pinned:
- open-agreements 134, dotnet/Open-XML-SDK 117 (55 ISO-Strict + 62 comment/commentsEx),
docx-platform-tests 28, SuperDoc docx-corpus 240 (stratified across 10 document types,
en/ru/zh), LibreOffice fuzzer-seed archive 1 (275 members extracted locally).
- Strata (documents may carry several): tables 279, headers/footers 264, multi-section 167,
drawings 96, comments 59, iso-strict 55, plain-body 49, vml 43, text-boxes 31, fields 30,
tracked-changes 20, embedded-objects 11, notes 7, content-controls 4, math 3, moves 1.
- A local OOXML feature classifier (`classify_docx_features.mjs`) derived the feature index;
0 unreadable packages across the SuperDoc sample.

## Stages completed

1. **Provenance/licensing review** — done (above).
2. **Manifest + fetch + classifier** — committed: `differential-corpus-manifest.json`,
`fetch_differential_corpus.mjs`, `classify_docx_features.mjs`.
3. **Deterministic smoke** — identity round-trip + self-comparison (both reconstruction
modes) over the full 520-doc corpus. Drivers enforced hard per-job timeouts and CPU
concurrency 4.
4. **External-oracle hardening** — LibreOffice cross-process lockfile added + unit-tested +
live two-process probe; Aspose licensed and watermark-verified.
5. **Corpus-scale run** — metamorphic mutation pairs (9 recipes × 2 modes over 64
stratified docs) + package/parser fuzz (8 mutation ops over 30 stratified bases + 275
LibreOffice fuzzer seeds).

## Counts (by taxonomy)

- **Smoke, local shard** (171 docs, 513 jobs): 494 pass; the 19 non-pass were all
rebuild-mode pre-tracked-input mismatches (finding F1) — self-inflicted invariant bug in
an early harness draft was corrected to projection-to-projection, isolating F1.
- **Smoke, external shard** (358 docs, 1083 jobs): 847 pass; 179 unsupported-undocumented
(all the BOM ParseError, finding F2); 30 supported-refusal (1 real `OpaquePassthrough` +
29 self-inflicted build-race, re-run green); 27 rebuild accept-mismatch (F1/F3-class).
- **Metamorphic** (64 docs): 551 pass, 244 skipped (recipe inapplicable), plus mismatches
that all reduced to F1 (rebuild pre-tracked unwrap) or F3 (rebuild VML/text-box story
loss). Every `inplace` comparison satisfied the reject→original / accept→revised
invariant; failures were rebuild-only. Two initially-flagged "phantom-revisions" and all
"crash" rows were **harness** bugs (a cloned `w:tab`; a missing strict-namespace bind),
fixed and retracted.
- **Fuzz** (515 jobs): validity-preserving mutations behaved; deliberately-invalid mutations
failed closed; **1 genuine engine finding (F4, OOM)** on a valid LibreOffice fuzzer seed.
`invalid-no-content-types` "invalid-accepted" rows reflect that a package missing
`[Content_Types].xml` still round-trips via the load path — noted, not filed (arguably
lenient-but-safe).

## Novel findings (minimized; issues filed)

- **F4 — quadratic-memory OOM in comparison → issue #874 (filed).**
`computeAtomLcs` allocates an unconditional O(n·m) DP matrix; a single paragraph with a
few thousand atoms exhausts the heap (SIGABRT). Delta-debugged from a 204 KB fuzzer seed
to the atom-count mechanism; deterministic synthetic repro committed at
`generate_oom_repro.mjs` (invented text). Reproduces on clean `main`.
- **F2 — BOM-prefixed document.xml → issue #875 (filed).**
A UTF-8 BOM at the start of `word/document.xml` (as Microsoft's ISO-Strict exports emit —
56/121 Open-XML-SDK files) throws a raw xmldom `ParseError` from both `DocxDocument.load`
and `compareDocuments` instead of loading or failing closed. Minimal synthetic repro in
the issue. Also notes Strict docs silently projecting to near-empty text. Clean `main`.
- **F1 — rebuild mode unwraps pre-existing tracked changes → commented on issue #742.**
Rebuild-mode comparison drops `<w:del>`/`<w:moveFrom|To>` wrappers from already-tracked
inputs, emitting bare `<w:delText>` (the Word-unreadable shape #742 reports); `inplace`
preserves them. Precise mechanism + minimal repro added to #742 (same family as #582);
no new issue to avoid duplication.

## Oracle agreements / disagreements

- **LibreOffice accept/reject oracle:** ran under the new cross-process lock; two concurrent
processes each completed full oracle batches, serialized, lock self-cleaned. No
disagreement observed on the sampled tracked-change accept/reject shapes.
- **Aspose:** licensed run verified. Not used as a blocking voter this session (see below);
no oracle disagreement to report.

## Aspose watermark verification

Confirmed the swallowed-license-failure defect in `aspose_compare.py` (globs `*.lic`,
`except Exception: pass`): an unlicensed run and an initially-mismatched license both emitted
**evaluation-watermarked** output while exiting 0. Root cause found: the on-disk license
allows product versions released before 2025-11-02, but `pip`'s default `aspose-words`
(26.7.0) is newer, so `set_license` raised `InvalidOperationException` — silently swallowed.
Pinning `aspose-words==25.10.0` and loading the license made output **verified
non-watermarked** (checked for "Evaluation Only" / "evaluation copy" in `word/document.xml`).
Recommendation captured below.

## Resource / environmental notes

- No uncontrolled process leak. Every worker ran under a hard per-job timeout; the only
process death was the OOM seed (SIGABRT under a capped heap), which the driver classified,
not a leak.
- One early self-inflicted build-race (running `npm run build -w docx-core`, which cleans
`dist`, while the smoke driver was live) produced 29 spurious "Cannot find package"
refusals; re-run green. Do not rebuild a package mid-run.
- LibreOffice #627 (macOS headless startup crash / parallel amplification) did **not**
reproduce with the lock in place. It **did** reproduce spontaneously in the environment:
12 leaked headless soffice processes (1–2 day elapsed, 0% CPU/MEM, hung after startup),
whose referenced throwaway profile dirs had already been `rmSync`'d by the parent's
`finally` — the child outlived the parent's cleanup. Evidence posted to #627. None were
attributable to this session, and per the shared-machine kill ban I killed nothing.
The stage-4 lock closes the concurrency-amplification vector but not the reaping gap; a
process-group/stale-profile reaper is recommended on #627 but **deliberately not shipped
tonight** — killing processes on a shared machine under the `pkill` ban is unsafe without
perfect attribution.

## Recommended next queue

1. Fix F4 (#874): linear-space/Hirschberg LCS or a size-guarded bounded refusal — highest
severity (DoS on ordinary content).
2. Fix F2 (#875): strip/tolerate a leading BOM on XML parts before parsing; decide ISO-Strict
scope and make ingest either support it or refuse with an actionable error.
3. Land F1 under #742/#582: rebuild must preserve pre-existing revision markup or fail closed.
4. Harden `aspose_compare.py`: fail loudly on `set_license` errors, assert the output is not
watermarked, and pin a license-compatible `aspose-words` version (≤ the license's
free-upgrade date). Never swallow the license exception.
5. Consider promoting one deterministic, env-gated smoke harness (identity + self-compare)
from `.tmp/` into the tree once F1/F2/F4 are fixed — kept opt-in, never a default CI job.
Loading