export type ArchiveEntry = { path: string; compressedBytes: number; uncompressedBytes: number }
export type SecurityPreflightReport = { pathChecks: number; archiveEntries: number; xmlBytes: number; permissionChecks: number; pass: boolean; errors: string[] }
export type SecurityPreflightApi = { path(path: string): string; archive(entries: ArchiveEntry[], limits?: { maxEntries?: number; maxTotalBytes?: number; maxRatio?: number }): void; xml(xml: string, maxBytes?: number): void; permissions(permissions: string[], allowed?: string[]): void; report(): SecurityPreflightReport }
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
export const createSecurityPreflight = (): SecurityPreflightApi => { const errors: string[] = []; let pathChecks = 0; let archiveEntries = 0; let xmlBytes = 0; let permissionChecks = 0; const path = (value: string) => { pathChecks += 1; if (!value.trim() || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || value.split(/[\\/]+/).some((part) => part === '..')) { errors.push(`unsafe path: ${value}`); throw new Error('Unsafe resource path.') } return value.replaceAll('\\', '/') }; const archive = (entries: ArchiveEntry[], limits: { maxEntries?: number; maxTotalBytes?: number; maxRatio?: number } = {}) => { const maxEntries = limits.maxEntries ?? 10_000; const maxTotalBytes = limits.maxTotalBytes ?? 512 * 1024 * 1024; const maxRatio = limits.maxRatio ?? 100; archiveEntries += entries.length; if (entries.length > maxEntries) { errors.push('archive entry count exceeded'); throw new Error('Archive entry count exceeds the security limit.') } let total = 0; for (const entry of entries) { path(entry.path); if (!Number.isSafeInteger(entry.compressedBytes) || !Number.isSafeInteger(entry.uncompressedBytes) || entry.compressedBytes <= 0 || entry.uncompressedBytes < entry.compressedBytes) { errors.push('invalid archive sizes'); throw new Error('Archive entry sizes are invalid.') } total += entry.uncompressedBytes; if (entry.uncompressedBytes / entry.compressedBytes > maxRatio) { errors.push('archive ratio exceeded'); throw new Error('Archive compression ratio exceeds the security limit.') } } if (total > maxTotalBytes) { errors.push('archive total exceeded'); throw new Error('Archive uncompressed size exceeds the security limit.') } }; const xml = (value: string, maxBytes = 8 * 1024 * 1024) => { xmlBytes += new TextEncoder().encode(value).byteLength; if (xmlBytes > maxBytes || /<!DOCTYPE|<!ENTITY|SYSTEM\s+['"]/i.test(value)) { errors.push('unsafe XML'); throw new Error('XML contains a forbidden declaration or exceeds the size limit.') } }; const permissions = (values: string[], allowed = ['geometry.read', 'project.read']) => { permissionChecks += values.length; const accepted = new Set(allowed); if (values.some((value) => !accepted.has(value))) { errors.push('permission exceeded'); throw new Error('Permission is outside the security allowlist.') } }; const report = () => clone({ pathChecks, archiveEntries, xmlBytes, permissionChecks, pass: errors.length === 0, errors }); return { path, archive, xml, permissions, report } }
