P7/P4: harden recompute and FCStd boundaries
This commit is contained in:
243
src/facade/fcstd.ts
Normal file
243
src/facade/fcstd.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { unzipSync } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
export type FcstdArchiveLimits = {
|
||||
maxArchiveBytes: number
|
||||
maxEntries: number
|
||||
maxEntryBytes: number
|
||||
maxTotalUncompressedBytes: number
|
||||
maxCompressionRatio: number
|
||||
}
|
||||
|
||||
export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'thumbnail' | 'script' | 'resource'
|
||||
|
||||
export type FcstdEntryMetadata = {
|
||||
path: string
|
||||
compressedBytes: number
|
||||
uncompressedBytes: number
|
||||
compressionMethod: number
|
||||
role: FcstdEntryRole
|
||||
}
|
||||
|
||||
export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
|
||||
|
||||
export type FcstdObjectSummary = {
|
||||
name: string
|
||||
label: string
|
||||
typeId: string
|
||||
propertyCount: number
|
||||
support: FcstdObjectSupport
|
||||
}
|
||||
|
||||
export type FcstdCompatibilityReport = {
|
||||
level: 'metadata-compatible' | 'partial' | 'blocked'
|
||||
readOnly: true
|
||||
codeExecutionBlocked: true
|
||||
recognizedObjects: number
|
||||
proxyObjects: number
|
||||
blockedObjects: number
|
||||
unknownTypeIds: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type FcstdInspection = {
|
||||
format: 'FCStd'
|
||||
schemaVersion: string
|
||||
label: string
|
||||
entries: FcstdEntryMetadata[]
|
||||
objects: FcstdObjectSummary[]
|
||||
compatibility: FcstdCompatibilityReport
|
||||
}
|
||||
|
||||
export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
|
||||
maxArchiveBytes: 256 * 1024 * 1024,
|
||||
maxEntries: 20_000,
|
||||
maxEntryBytes: 128 * 1024 * 1024,
|
||||
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxCompressionRatio: 200,
|
||||
}
|
||||
|
||||
const recognizedTypeIds = new Set([
|
||||
'App::DocumentObjectGroup',
|
||||
'App::FeaturePython',
|
||||
'Part::Feature',
|
||||
'Part::FeaturePython',
|
||||
'PartDesign::Body',
|
||||
'PartDesign::Feature',
|
||||
'PartDesign::Pad',
|
||||
'PartDesign::Pocket',
|
||||
'PartDesign::Fillet',
|
||||
'PartDesign::Chamfer',
|
||||
'PartDesign::Revolution',
|
||||
'Sketcher::SketchObject',
|
||||
])
|
||||
|
||||
const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
|
||||
|
||||
const entryRole = (path: string): FcstdEntryRole => {
|
||||
const lower = path.toLowerCase()
|
||||
if (lower === 'document.xml') return 'document'
|
||||
if (lower === 'guidocument.xml') return 'gui-document'
|
||||
if (lower === 'thumbnails/thumbnail.png') return 'thumbnail'
|
||||
if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape'
|
||||
if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script'
|
||||
return 'resource'
|
||||
}
|
||||
|
||||
const validateEntryPath = (path: string) => {
|
||||
if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || '<empty>'}`)
|
||||
const segments = path.split('/')
|
||||
if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`)
|
||||
}
|
||||
|
||||
const findEndOfCentralDirectory = (bytes: Uint8Array) => {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const minimum = Math.max(0, bytes.byteLength - 65_557)
|
||||
for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
|
||||
if (view.getUint32(offset, true) === 0x06054b50) return offset
|
||||
}
|
||||
throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.')
|
||||
}
|
||||
|
||||
const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => {
|
||||
if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`)
|
||||
if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.')
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const eocd = findEndOfCentralDirectory(bytes)
|
||||
const diskNumber = view.getUint16(eocd + 4, true)
|
||||
const centralDirectoryDisk = view.getUint16(eocd + 6, true)
|
||||
const entriesOnDisk = view.getUint16(eocd + 8, true)
|
||||
const entryCount = view.getUint16(eocd + 10, true)
|
||||
const directorySize = view.getUint32(eocd + 12, true)
|
||||
const directoryOffset = view.getUint32(eocd + 16, true)
|
||||
const commentLength = view.getUint16(eocd + 20, true)
|
||||
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.')
|
||||
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.')
|
||||
if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`)
|
||||
if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.')
|
||||
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
const entries: FcstdEntryMetadata[] = []
|
||||
const paths = new Set<string>()
|
||||
let cursor = directoryOffset
|
||||
let totalUncompressed = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.')
|
||||
const flags = view.getUint16(cursor + 8, true)
|
||||
const compressionMethod = view.getUint16(cursor + 10, true)
|
||||
const compressedBytes = view.getUint32(cursor + 20, true)
|
||||
const uncompressedBytes = view.getUint32(cursor + 24, true)
|
||||
const nameLength = view.getUint16(cursor + 28, true)
|
||||
const extraLength = view.getUint16(cursor + 30, true)
|
||||
const entryCommentLength = view.getUint16(cursor + 32, true)
|
||||
const diskStart = view.getUint16(cursor + 34, true)
|
||||
const next = cursor + 46 + nameLength + extraLength + entryCommentLength
|
||||
if (next > eocd) throw new Error('FCStd central-directory entry is truncated.')
|
||||
if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.')
|
||||
if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.')
|
||||
if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`)
|
||||
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.')
|
||||
const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength))
|
||||
validateEntryPath(path)
|
||||
if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`)
|
||||
paths.add(path)
|
||||
if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`)
|
||||
const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes
|
||||
if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`)
|
||||
totalUncompressed += uncompressedBytes
|
||||
if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`)
|
||||
entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) })
|
||||
cursor = next
|
||||
}
|
||||
if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.')
|
||||
return entries
|
||||
}
|
||||
|
||||
const asArray = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
|
||||
|
||||
const attribute = (node: unknown, name: string): string => {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
const record = node as Record<string, unknown>
|
||||
const value = record[`@_${name}`] ?? record[name]
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
const propertyValue = (property: Record<string, unknown>): string => {
|
||||
for (const value of Object.values(property)) {
|
||||
if (!value || typeof value !== 'object') continue
|
||||
const candidate = attribute(value, 'value')
|
||||
if (candidate) return candidate
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const parseDocumentXml = (bytes: Uint8Array) => {
|
||||
const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('FCStd Document.xml declarations and entities are not allowed.')
|
||||
const parsed = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
parseTagValue: false,
|
||||
processEntities: false,
|
||||
allowBooleanAttributes: false,
|
||||
}).parse(xml) as Record<string, unknown>
|
||||
const root = (parsed.Document ?? parsed) as Record<string, unknown>
|
||||
const objectDeclarations = asArray((((root.Objects as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const objectData = asArray((((root.ObjectData as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
|
||||
const documentProperties = asArray((((root.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
|
||||
const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
|
||||
const name = attribute(declaration, 'name') || '<unnamed>'
|
||||
const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
|
||||
const data = dataByName.get(name)
|
||||
const properties = asArray((((data?.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
|
||||
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
|
||||
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support }
|
||||
})
|
||||
return {
|
||||
schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',
|
||||
label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document',
|
||||
objects,
|
||||
}
|
||||
}
|
||||
|
||||
export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdInspection => {
|
||||
const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
|
||||
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
|
||||
const entries = inspectZipDirectory(bytes, limits)
|
||||
const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml')
|
||||
if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.')
|
||||
const files = unzipSync(bytes)
|
||||
for (const entry of entries) {
|
||||
const content = files[entry.path]
|
||||
if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`)
|
||||
}
|
||||
const document = parseDocumentXml(files[documentEntry.path])
|
||||
const warnings: string[] = []
|
||||
const scriptEntries = entries.filter((entry) => entry.role === 'script')
|
||||
if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`)
|
||||
const blockedObjects = document.objects.filter((object) => object.support === 'blocked')
|
||||
if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`)
|
||||
const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
|
||||
if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
|
||||
const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
|
||||
return {
|
||||
format: 'FCStd',
|
||||
schemaVersion: document.schemaVersion,
|
||||
label: document.label,
|
||||
entries,
|
||||
objects: document.objects,
|
||||
compatibility: {
|
||||
level,
|
||||
readOnly: true,
|
||||
codeExecutionBlocked: true,
|
||||
recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length,
|
||||
proxyObjects: proxyObjects.length,
|
||||
blockedObjects: blockedObjects.length,
|
||||
unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(),
|
||||
warnings,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user