Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Accepted XML whitespace before exact Microsoft Project element delimiters
while preserving the linear, regex-free import scanner and rejecting
attributes, longer names, non-XML whitespace, nested unmatched blocks, and
truncated input.
- Attachment-list status refresh now removes the per-row database lookup,
uses a configurable bounded worker pool with per-item abortable timeouts and
a request-wide latency budget, preserves stale status after downstream,
Expand Down
57 changes: 39 additions & 18 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -741,33 +741,54 @@ function openReportModal() {
export function parseMsProjectXml(xml) {
// Fully linear extract (indexOf/slice) — no dynamic RegExp and no lazy
// [\s\S]*? block collectors (those can quadratic-backtrack on truncated input).
const isXmlWhitespace = (charCode) => (
charCode === 0x20 || charCode === 0x09 || charCode === 0x0d || charCode === 0x0a
);
const findTagBoundary = (source, name, from, closing = false) => {
const prefix = `<${closing ? '/' : ''}${name}`;
let searchFrom = from;
for (;;) {
const start = source.indexOf(prefix, searchFrom);
if (start === -1) return null;
let delimiter = start + prefix.length;
while (delimiter < source.length && isXmlWhitespace(source.charCodeAt(delimiter))) {
delimiter += 1;
}
if (source.charCodeAt(delimiter) === 0x3e) {
return { start, end: delimiter + 1 };
}
// Reject attributes, longer names, and non-XML whitespace while advancing
// past every inspected byte so malformed candidates are never rescanned.
searchFrom = Math.max(delimiter + 1, start + prefix.length);
}
};
const tag = (block, name) => {
const openingTag = `<${name}>`;
const closingTag = `</${name}>`;
const valueStart = block.indexOf(openingTag);
if (valueStart === -1) return '';
const contentStart = valueStart + openingTag.length;
const valueEnd = block.indexOf(closingTag, contentStart);
return valueEnd === -1 ? '' : block.slice(contentStart, valueEnd).trim();
const opening = findTagBoundary(block, name, 0);
if (!opening) return '';
const closing = findTagBoundary(block, name, opening.end, true);
const nextOpening = findTagBoundary(block, name, opening.end);
if (!closing || (nextOpening && nextOpening.start < closing.start)) return '';
return block.slice(opening.end, closing.start).trim();
};
const collectBlocks = (source, openTag, closeTag) => {
const collectBlocks = (source, name) => {
const out = [];
let from = 0;
for (;;) {
const start = source.indexOf(openTag, from);
if (start === -1) break;
const contentStart = start + openTag.length;
const end = source.indexOf(closeTag, contentStart);
// Incomplete open tag: stop linearly (do not rescan the remainder).
if (end === -1) break;
out.push(source.slice(start, end + closeTag.length));
from = end + closeTag.length;
const opening = findTagBoundary(source, name, from);
if (!opening) break;
const closing = findTagBoundary(source, name, opening.end, true);
const nextOpening = findTagBoundary(source, name, opening.end);
// Incomplete or nested same-name block: stop at the first unmatched
// opening tag instead of pairing it with a later block's closing tag.
if (!closing || (nextOpening && nextOpening.start < closing.start)) break;
out.push(source.slice(opening.start, closing.end));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
from = closing.end;
}
return out;
};
const predecessorIds = (block) => {
const ids = [];
for (const link of collectBlocks(block, '<PredecessorLink>', '</PredecessorLink>')) {
for (const link of collectBlocks(block, 'PredecessorLink')) {
const uid = tag(link, 'PredecessorUID');
if (/^\d+$/.test(uid)) ids.push(`msp-${uid}`);
}
Expand All @@ -779,7 +800,7 @@ export function parseMsProjectXml(xml) {
const day = (s) => (/^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : '');
const tasks = [];
const parents = {}; // depth -> last task id at that depth
const blocks = collectBlocks(String(xml || ''), '<Task>', '</Task>');
const blocks = collectBlocks(String(xml || ''), 'Task');
for (const block of blocks) {
const uid = tag(block, 'UID');
const name = unescape(tag(block, 'Name'));
Expand Down
63 changes: 63 additions & 0 deletions docs/doctoring/ms-project-xml-import-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Microsoft Project XML delimiter boundary

## Decision

ScopeWeave's Microsoft Project import profile accepts XML whitespace between an
exact supported element name and the closing `>` delimiter. The accepted code
points are:

- U+0020 SPACE;
- U+0009 CHARACTER TABULATION;
- U+000D CARRIAGE RETURN; and
- U+000A LINE FEED.

The parser deliberately does not become a general XML processor. It recognizes
only the exact `Task`, `PredecessorLink`, and scalar element names already used
by the import adapter. Attributes, namespace prefixes, longer lookalike names,
non-XML whitespace, self-closing forms, nested same-name blocks, and truncated
blocks are rejected or yield no value under this narrow profile.

## Security and complexity boundary

The scanner remains monotonic and regex-free. It advances through every rejected
candidate and uses bounded `indexOf()` and `slice()` operations rather than
constructing dynamic regular expressions or lazy whole-document block matches.
This preserves the existing denial-of-service boundary for malformed or
adversarial uploads.

An unmatched outer element cannot consume a later nested element's closing tag.
If another same-name opening appears before the candidate closing tag, block
collection stops at the unmatched outer element instead of silently producing a
mis-parented task.

## Executable evidence

`tests/unit/msproject.test.mjs` covers:

- space, tab, carriage-return, and line-feed delimiters;
- scalar and predecessor-link elements using each allowed delimiter;
- an actual U+000B vertical tab, which is not XML whitespace;
- attributes and longer element names;
- truncated and repeated unclosed task blocks;
- nested same-name openings before a closing element; and
- the existing valid import and predecessor contracts.

The test is already part of the full unit and coverage command paths. No package
or lockfile change is required.

## Compatibility and rollback

The change broadens acceptance only for documents that are conformant with the
XML whitespace production at the delimiter positions used by this adapter.
Existing byte-exact exports retain the same task identifiers, names, dates,
parents, progress, and predecessor values.

Rollback must revert the scanner, focused tests, security documentation,
CHANGELOG entry, and this record together. Reintroducing byte-exact delimiters
would again reject standards-compliant Microsoft Project exports that contain
formatting whitespace before `>`.

## Reference

World Wide Web Consortium. (2008). *Extensible Markup Language (XML) 1.0
(Fifth Edition)*. https://www.w3.org/TR/2008/REC-xml-20081126/
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Every user-controlled CSV cell is neutralized when, after optional leading white

## XML imports

Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking.
Microsoft Project XML extraction uses bounded `indexOf`/`slice` loops. Opening and closing `Task`, `PredecessorLink`, and scalar tags accept only XML whitespace (space, tab, carriage return, or line feed) between the exact element name and `>`. Attributes, longer names, and other whitespace code points are not accepted by this deliberately narrow import profile. Dynamic regular expressions and lazy whole-document block collectors are prohibited because truncated or adversarial input can cause catastrophic backtracking.

## Release verification

Expand Down
49 changes: 49 additions & 0 deletions tests/unit/msproject.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,53 @@ assert.deepEqual(
const incompleteOpens = `<Project><Tasks>${'<Task><UID>9</UID><Name>open</Name>'.repeat(5000)}</Tasks></Project>`;
assert.deepEqual(parseMsProjectXml(incompleteOpens), [], 'unclosed Task blocks yield no tasks');

assert.deepEqual(
parseMsProjectXml(
'<Project><Tasks><Task><UID>11</UID><Name>unclosed outer</Name><Task><UID>12</UID><Name>nested</Name></Task></Tasks></Project>',
),
[],
'an unmatched outer Task cannot consume a nested Task closing tag',
);

const whitespaceTags = parseMsProjectXml(`
<Project><Tasks><Task \t>
<UID\t>8</UID \r\n>
<Name >Whitespace-compatible task</Name >
<OutlineLevel\n>1</OutlineLevel\t>
<Start >2026-08-11T09:00:00</Start\r>
<Finish\t>2026-08-12T17:00:00</Finish >
<PredecessorLink \n>
<PredecessorUID >2</PredecessorUID\t>
</PredecessorLink >
</Task \n></Tasks></Project>`);
assert.equal(whitespaceTags.length, 1, 'XML whitespace before tag delimiters is accepted');
assert.equal(whitespaceTags[0].id, 'msp-8');
assert.equal(whitespaceTags[0].phase, 'Whitespace-compatible task');
assert.equal(whitespaceTags[0].plannedStartDate, '2026-08-11');
assert.equal(whitespaceTags[0].plannedEndDate, '2026-08-12');
assert.equal(whitespaceTags[0].predecessors, 'msp-2', 'block and scalar tags share the scanner');

assert.deepEqual(
parseMsProjectXml('<Project><Tasks><TaskX><UID>9</UID><Name>wrong</Name></TaskX></Tasks></Project>'),
[],
'TaskX must not match Task',
);
assert.deepEqual(
parseMsProjectXml('<Project><Tasks><Task\u000B><UID>9</UID><Name>wrong whitespace</Name></Task\u000B></Tasks></Project>'),
[],
'non-XML whitespace before a delimiter is rejected',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert.deepEqual(
parseMsProjectXml('<Project><Tasks><Task \t><UID >10</UID ><Name >truncated</Name >'),
[],
'truncated whitespace-delimited Task stops safely',
);
assert.deepEqual(
parseMsProjectXml(
'<Project><Tasks><Task><UID>13</UID><Name>outer<Name>inner</Name><OutlineLevel>1</OutlineLevel></Task></Tasks></Project>',
),
[],
'a nested scalar opening cannot consume the inner closing delimiter',
);

console.log('✓ MS Project import tests passed');
Loading