feat: release Office Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
OdfParseError,
|
||||
parseOdt,
|
||||
parseOpenDocument,
|
||||
} from "../../../src/office/odf";
|
||||
import {
|
||||
corruptFirstCentralCrc,
|
||||
makeManifest,
|
||||
makeOdf,
|
||||
MEDIA_TYPES,
|
||||
odtContent,
|
||||
odsContent,
|
||||
XMLNS,
|
||||
zipWithEncodedCollision,
|
||||
zipWithTraversal,
|
||||
} from "./fixtures";
|
||||
|
||||
function expectOdfError(
|
||||
action: () => unknown,
|
||||
code: OdfParseError["code"],
|
||||
): void {
|
||||
try {
|
||||
action();
|
||||
throw new Error("Expected ODF parser to reject the fixture");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(OdfParseError);
|
||||
expect((error as OdfParseError).code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenDocument package and XML security boundaries", () => {
|
||||
it("rejects non-ZIP input and unsafe traversal paths", () => {
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(new Uint8Array([1, 2, 3]).buffer),
|
||||
"invalid-zip",
|
||||
);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
zipWithTraversal(odtContent("<text:p>Safe</text:p>")),
|
||||
),
|
||||
"invalid-path",
|
||||
);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
zipWithEncodedCollision(odtContent("<text:p>Safe</text:p>")),
|
||||
),
|
||||
"invalid-path",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects CRC corruption", () => {
|
||||
const fixture = makeOdf("odt", odtContent("<text:p>Integrity</text:p>"));
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(corruptFirstCentralCrc(fixture)),
|
||||
"invalid-zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects expected-format, filename, manifest, and body type mismatches", () => {
|
||||
const ods = makeOdf("ods", odsContent('<table:table table:name="S"/>'));
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(ods, { expectedFormat: "odt" }),
|
||||
"type-mismatch",
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(ods, { fileName: "wrong.odt" }),
|
||||
"type-mismatch",
|
||||
);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
makeOdf("odt", odtContent("<text:p>x</text:p>"), {
|
||||
manifest: makeManifest(MEDIA_TYPES.ods),
|
||||
}),
|
||||
),
|
||||
"type-mismatch",
|
||||
);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOdt(makeOdf("odt", odsContent('<table:table table:name="S"/>'))),
|
||||
"type-mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects encrypted package declarations", () => {
|
||||
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml">
|
||||
<manifest:encryption-data/>
|
||||
</manifest:file-entry></manifest:manifest>`;
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
makeOdf("odt", odtContent("<text:p>x</text:p>"), { manifest }),
|
||||
),
|
||||
"encrypted",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts manifest directories represented implicitly by child entries", () => {
|
||||
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
|
||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||
<manifest:file-entry manifest:full-path="Object 1/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
||||
</manifest:manifest>`;
|
||||
const document = parseOpenDocument(
|
||||
makeOdf("odt", odtContent("<text:p>Safe</text:p>"), {
|
||||
manifest,
|
||||
extra: {
|
||||
"Object 1/content.xml": new TextEncoder().encode("unused"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(document.format).toBe("odt");
|
||||
});
|
||||
|
||||
it("rejects malformed XML, DTDs/entities, and excessive XML depth", () => {
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(makeOdf("odt", "<office:document-content>")),
|
||||
"invalid-xml",
|
||||
);
|
||||
const entity = `<?xml version="1.0"?><!DOCTYPE x [<!ENTITY local "bad">]>
|
||||
<office:document-content ${XMLNS}><office:body><office:text><text:p>&local;</text:p></office:text></office:body></office:document-content>`;
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(makeOdf("odt", entity)),
|
||||
"invalid-xml",
|
||||
);
|
||||
const nested =
|
||||
`<text:section>`.repeat(8) +
|
||||
`<text:p>x</text:p>` +
|
||||
`</text:section>`.repeat(8);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(makeOdf("odt", odtContent(nested)), {
|
||||
limits: { maxXmlDepth: 6 },
|
||||
}),
|
||||
"limit-exceeded",
|
||||
);
|
||||
const compact = `<?xml version="1.0"?><office:document-content ${XMLNS}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>`;
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(makeOdf("odt", compact, { manifest: false }), {
|
||||
limits: { maxXmlNodes: 4 },
|
||||
}),
|
||||
"limit-exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects dangerous external images and active links", () => {
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
makeOdf(
|
||||
"odt",
|
||||
odtContent(
|
||||
'<draw:frame><draw:image xlink:href="https://tracker.example/image.png"/></draw:frame>',
|
||||
),
|
||||
),
|
||||
),
|
||||
"external-resource",
|
||||
);
|
||||
expectOdfError(
|
||||
() =>
|
||||
parseOpenDocument(
|
||||
makeOdf(
|
||||
"odt",
|
||||
odtContent(
|
||||
'<text:p><text:a xlink:href="javascript:alert(1)">bad</text:a></text:p>',
|
||||
),
|
||||
),
|
||||
),
|
||||
"external-resource",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps embedded objects inert and reports their omission", () => {
|
||||
const document = parseOpenDocument(
|
||||
makeOdf("odt", odtContent('<draw:object xlink:href="Object 1"/>')),
|
||||
);
|
||||
if (document.format !== "odt") throw new Error("Expected an ODT model");
|
||||
expect(document.blocks).toEqual([]);
|
||||
expect(document.warnings).toContain(
|
||||
"1 embedded object was kept inert and omitted",
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces entry, total, text, asset, repeat, and expanded-cell limits", () => {
|
||||
const basic = makeOdf("odt", odtContent("<text:p>123456789</text:p>"));
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(basic, { limits: { maxEntryBytes: 32 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(basic, { limits: { maxTotalBytes: 50 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(basic, { limits: { maxEntries: 2 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(basic, { limits: { maxTextChars: 5 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
|
||||
const repeated = makeOdf(
|
||||
"ods",
|
||||
odsContent(
|
||||
'<table:table table:name="Huge"><table:table-row table:number-rows-repeated="4"><table:table-cell table:number-columns-repeated="4" office:value-type="string"><text:p>x</text:p></table:table-cell></table:table-row></table:table>',
|
||||
),
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(repeated, { limits: { maxRepeat: 3 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(repeated, { limits: { maxCells: 15 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
|
||||
const image = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4]);
|
||||
const imageDocument = makeOdf(
|
||||
"odt",
|
||||
odtContent(
|
||||
'<draw:frame><draw:image xlink:href="Pictures/a.png"/></draw:frame>',
|
||||
),
|
||||
{ extra: { "Pictures/a.png": image } },
|
||||
);
|
||||
expectOdfError(
|
||||
() => parseOpenDocument(imageDocument, { limits: { maxAssetBytes: 4 } }),
|
||||
"limit-exceeded",
|
||||
);
|
||||
});
|
||||
|
||||
it("validates option limits before reading untrusted input", () => {
|
||||
expect(() =>
|
||||
parseOpenDocument(new ArrayBuffer(0), { limits: { maxEntries: 0 } }),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user