import path from "node:path"; import { sha256Bytes } from "../core/hash.ts"; import { fail } from "../errors.ts"; import { classifyPart, classifyRelationshipType, type PartClassification } from "../core/protected-parts.ts"; import type { DocxLimits } from "../core/limits.ts"; import { SafeZipArchive } from "./zip.ts"; import { NS, elements, parseXml } from "./xml.ts"; const posix = path.posix; export type PackageRelationship = { relsPart: string; sourcePart: string; id: string; type: string; target: string; targetMode?: string; resolvedTarget?: string }; export type ManifestPart = { path: string; contentType?: string; compressedBytes: number; uncompressedBytes: number; crc32: string; sha256: string; classification: PartClassification; relationshipTargets: string[] }; export type PackageManifest = { packageSha256?: string; mainDocumentPart: string; parts: ManifestPart[]; relationships: PackageRelationship[]; externalRelationships: PackageRelationship[]; protectedParts: string[]; activeContentParts: string[]; signedParts: string[] }; export type IntegrityComparison = { ok: boolean; errors: string[]; changedParts: string[]; addedParts: string[]; removedParts: string[]; protectedPartsChanged: string[] }; function sourceFromRels(rels: string): string | undefined { if (rels === "_rels/.rels") return ""; const match = rels.match(/^(.*)\/_rels\/([^/]+)\.rels$/); return match ? `${match[1]}/${match[2]}` : undefined; } function resolveTarget(source: string, target: string): string | undefined { if (!target || target.startsWith("#") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) || target.startsWith("//")) return undefined; const resolved = posix.normalize(posix.join(source ? posix.dirname(source) : "", target.replace(/^\//, ""))); return resolved === ".." || resolved.startsWith("../") || posix.isAbsolute(resolved) ? undefined : resolved; } export class OoxmlPackage { readonly archive: SafeZipArchive; readonly contentTypes: Map; readonly relationships: PackageRelationship[]; readonly mainDocumentPart: string; readonly classifications: Map; private constructor(archive: SafeZipArchive) { this.archive = archive; this.contentTypes = this.readContentTypes(); this.relationships = this.readRelationships(); this.mainDocumentPart = this.findMainPart(); this.validateTargets(); this.classifications = this.classify(); } static fromBytes(bytes: Uint8Array, limits?: Partial): OoxmlPackage { return new OoxmlPackage(SafeZipArchive.fromBytes(bytes, limits)); } private readContentTypes(): Map { const part = "[Content_Types].xml", document = parseXml(this.archive.require(part), part, this.archive.limits.maxXmlBytes); if (document.documentElement.namespaceURI !== NS.contentTypes || document.documentElement.localName !== "Types") fail("INVALID_PACKAGE", `${part} has an unexpected root.`); const defaults = new Map(), overrides = new Map(); for (const node of elements(document, NS.contentTypes, "Default")) { const ext = node.getAttribute("Extension")?.toLowerCase(), type = node.getAttribute("ContentType"); if (ext && type) { if (defaults.has(ext)) fail("INVALID_PACKAGE", `Duplicate content-type default for .${ext}.`); defaults.set(ext, type); } } for (const node of elements(document, NS.contentTypes, "Override")) { const name = node.getAttribute("PartName")?.replace(/^\//, ""), type = node.getAttribute("ContentType"); if (name && type) { if (overrides.has(name)) fail("INVALID_PACKAGE", `Duplicate content-type override for ${name}.`); overrides.set(name, type); } } for (const entry of this.archive.entries.keys()) if (!overrides.has(entry)) { const type = defaults.get(posix.extname(entry).slice(1).toLowerCase()); if (type) overrides.set(entry, type); } return overrides; } private readRelationships(): PackageRelationship[] { const result: PackageRelationship[] = []; for (const relsPart of this.archive.entries.keys()) { if (!relsPart.endsWith(".rels")) continue; const sourcePart = sourceFromRels(relsPart); if (sourcePart === undefined) continue; const document = parseXml(this.archive.require(relsPart), relsPart, this.archive.limits.maxXmlBytes); if (document.documentElement.namespaceURI !== NS.relationships) fail("INVALID_PACKAGE", `${relsPart} has an unexpected relationship namespace.`); const ids = new Set(); for (const node of elements(document, NS.relationships, "Relationship")) { const id = node.getAttribute("Id") ?? "", type = node.getAttribute("Type") ?? "", target = node.getAttribute("Target") ?? "", targetMode = node.getAttribute("TargetMode") ?? undefined; if (!id || !type || !target || ids.has(id)) fail("INVALID_PACKAGE", `Invalid or duplicate relationship in ${relsPart}.`); ids.add(id); const external = targetMode?.toLowerCase() === "external"; const resolvedTarget = external ? undefined : resolveTarget(sourcePart, target); if (!external && !resolvedTarget) fail("INVALID_PACKAGE", `Unsafe relationship target ${target} in ${relsPart}.`); result.push({ relsPart, sourcePart, id, type, target, targetMode, resolvedTarget }); } } return result; } private findMainPart(): string { const roots = this.relationships.filter((rel) => rel.sourcePart === "" && /\/officeDocument$/i.test(rel.type)); if (roots.length !== 1 || !roots[0].resolvedTarget) fail("INVALID_PACKAGE", "Package must contain exactly one internal officeDocument relationship."); const candidate = roots[0].resolvedTarget; if (!this.archive.get(candidate)) fail("INVALID_PACKAGE", `Main document part is missing: ${candidate}.`); const type = this.contentTypes.get(candidate) ?? ""; if (!/(?:wordprocessingml\.(?:document|template)\.main|ms-word\.(?:document|template)\.macroEnabled\.main)\+xml/i.test(type)) fail("INVALID_PACKAGE", `Main part has unexpected content type: ${type || "missing"}.`); return candidate; } private validateTargets(): void { for (const rel of this.relationships) if (rel.resolvedTarget && !this.archive.get(rel.resolvedTarget)) fail("INVALID_PACKAGE", `Relationship ${rel.id} targets missing part ${rel.resolvedTarget}.`); } private classify(): Map { const byTarget = new Map(); for (const rel of this.relationships) if (rel.resolvedTarget) byTarget.set(rel.resolvedTarget, [...(byTarget.get(rel.resolvedTarget) ?? []), rel.type]); const result = new Map(); for (const part of this.archive.entries.keys()) result.set(part, classifyPart(part, this.contentTypes.get(part), byTarget.get(part))); for (const relationship of this.relationships) { const relationshipClass = classifyRelationshipType(relationship.type); if (!relationshipClass) continue; const current = result.get(relationship.relsPart); if (relationshipClass === "signed" || current !== "signed") result.set(relationship.relsPart, relationshipClass); if (relationship.resolvedTarget) { const targetCurrent = result.get(relationship.resolvedTarget); if (relationshipClass === "signed" || targetCurrent !== "signed") result.set(relationship.resolvedTarget, relationshipClass); } } return result; } relationshipsFrom(sourcePart: string): PackageRelationship[] { return this.relationships.filter((rel) => rel.sourcePart === sourcePart); } manifest(packageSha256?: string): PackageManifest { const targets = new Map(); for (const rel of this.relationships) targets.set(rel.sourcePart, [...(targets.get(rel.sourcePart) ?? []), rel.resolvedTarget ?? rel.target]); const parts = [...this.archive.entries.values()].map((entry) => ({ path: entry.path, contentType: this.contentTypes.get(entry.path), compressedBytes: entry.compressedSize, uncompressedBytes: entry.uncompressedSize, crc32: entry.crc32.toString(16).padStart(8, "0"), sha256: sha256Bytes(entry.data), classification: this.classifications.get(entry.path)!, relationshipTargets: (targets.get(entry.path) ?? []).sort() })).sort((a, b) => a.path.localeCompare(b.path)); const select = (kind: PartClassification) => parts.filter((part) => part.classification === kind).map((part) => part.path); return { packageSha256, mainDocumentPart: this.mainDocumentPart, parts, relationships: this.relationships, externalRelationships: this.relationships.filter((rel) => rel.targetMode?.toLowerCase() === "external"), protectedParts: [...select("protected"), ...select("unsupported")].sort(), activeContentParts: select("active-content"), signedParts: select("signed") }; } assertMutationAllowed(): void { const manifest = this.manifest(); if (manifest.signedParts.length) fail("SIGNED_DOCUMENT", "Signed documents cannot be mutated in P1.", { signedParts: manifest.signedParts }); if (manifest.activeContentParts.length) fail("ACTIVE_CONTENT_BLOCKED", "Macro, ActiveX, OLE, attached-template, or other active content blocks P1 mutation.", { activeContentParts: manifest.activeContentParts }); } compareIntegrity(after: OoxmlPackage, allowedChangedParts: Set): IntegrityComparison { const errors: string[] = [], changedParts: string[] = [], addedParts: string[] = [], removedParts: string[] = [], protectedPartsChanged: string[] = []; for (const [partPath, before] of this.archive.entries) { const next = after.archive.entries.get(partPath); if (!next) { removedParts.push(partPath); errors.push(`Part removed: ${partPath}`); if (this.classifications.get(partPath) !== "editable") protectedPartsChanged.push(partPath); } else if (sha256Bytes(before.data) !== sha256Bytes(next.data)) { changedParts.push(partPath); const classification = this.classifications.get(partPath); if (!allowedChangedParts.has(partPath)) errors.push(`Unexpected part changed: ${partPath}`); if (classification && classification !== "editable") protectedPartsChanged.push(partPath); } } for (const p of after.archive.entries.keys()) if (!this.archive.entries.has(p)) { addedParts.push(p); if (!allowedChangedParts.has(p)) errors.push(`Unexpected part added: ${p}`); } return { ok: errors.length === 0 && protectedPartsChanged.length === 0, errors, changedParts: changedParts.sort(), addedParts: addedParts.sort(), removedParts: removedParts.sort(), protectedPartsChanged: [...new Set(protectedPartsChanged)].sort() }; } }