import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { spawnSync } from 'node:child_process' import { resolve } from 'node:path' import { isDeepStrictEqual } from 'node:util' import { unzipSync } from 'fflate' import { createWebCadFacade } from '../src/facade/mockFacade' import type { IncludedFileValue } from '../src/facade/types' const root = resolve(new URL('..', import.meta.url).pathname) const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd') const sysroot = resolve(root, '.cache/freecad/sysroot') const scriptPath = resolve(root, 'scripts/freecad-property-fileincluded-roundtrip.py') const outputDirectory = resolve(root, '.cache/freecad/property-fileincluded-roundtrip') const initialAssetPath = resolve(outputDirectory, 'initial.bin') const targetAssetPath = resolve(outputDirectory, 'target.bin') const nativePath = resolve(outputDirectory, 'native-initial.FCStd') const webPath = resolve(outputDirectory, 'web-edited.FCStd') const resavedPath = resolve(outputDirectory, 'native-resaved.FCStd') const reportPath = resolve(root, 'config/freecad-property-fileincluded-roundtrip.json') if (!existsSync(executable)) throw new Error(`FreeCAD PropertyFileIncluded roundtrip executable is missing: ${executable}`) await mkdir(outputDirectory, { recursive: true }) const initialAsset = new Uint8Array([70, 114, 101, 101, 67, 65, 68, 0, 17, 34, 51, 255, 10]) const targetAsset = new Uint8Array([87, 101, 98, 45, 101, 100, 105, 116, 101, 100, 0, 128, 64, 32, 16, 8, 4, 2, 1, 10]) await writeFile(initialAssetPath, initialAsset) await writeFile(targetAssetPath, targetAsset) const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex') const runNative = (mode: 'create' | 'verify', path: string, resaved = '') => { const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), scriptPath], { cwd: root, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, FREECAD_PROPERTY_FILEINCLUDED_ROUNDTRIP_MODE: mode, FREECAD_PROPERTY_FILEINCLUDED_ROUNDTRIP_PATH: path, FREECAD_PROPERTY_FILEINCLUDED_ROUNDTRIP_RESAVED_PATH: resaved, FREECAD_PROPERTY_FILEINCLUDED_ROUNDTRIP_SOURCE: initialAssetPath, PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`, LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`, }, }) const output = `${execution.stdout || ''}\n${execution.stderr || ''}` const marker = 'FREECAD_PROPERTY_FILEINCLUDED_ROUNDTRIP_RESULT=' const line = output.split(/\r?\n/).find((candidate) => candidate.includes(marker)) if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD PropertyFileIncluded ${mode} probe failed with status ${execution.status}: ${execution.error?.message || output.trim()}`) return JSON.parse(line.slice(line.indexOf(marker) + marker.length)) } const native = runNative('create', nativePath) const nativeBytes = new Uint8Array(await readFile(nativePath)) const facade = createWebCadFacade({ runtimeMode: 'mock' }) const inspectedBefore = facade.project.fcstd.inspect(nativeBytes) const property = (inspection: typeof inspectedBefore) => inspection.objects.find((object) => object.name === 'IncludedProbe')?.properties.find((candidate) => candidate.name === 'File') const imported = await facade.project.fcstd.storeIncludedFiles(nativeBytes) if (imported.length !== 1 || imported[0].path !== 'payload.bin') throw new Error('Native PropertyFileIncluded archive did not expose one payload.bin resource.') const expectedValue = imported[0].value const targetResource = await facade.project.resource.put(targetAsset, 'application/octet-stream') const targetValue: IncludedFileValue = { schemaVersion: 1, archiveName: expectedValue.archiveName, resourceHash: targetResource.hash, byteLength: targetAsset.byteLength, mediaType: 'application/octet-stream' } const editedBytes = await facade.project.fcstd.rewriteIncludedFile(nativeBytes, { objectName: 'IncludedProbe', propertyName: 'File', value: targetValue, expectedValue }) const inspectedAfter = facade.project.fcstd.inspect(editedBytes) await writeFile(webPath, editedBytes) const initialFiles = unzipSync(nativeBytes) const editedFiles = unzipSync(editedBytes) const resourcePath = property(inspectedBefore)?.resourcePath ?? '' const opaquePaths = Object.keys(initialFiles).filter((path) => path.toLowerCase() !== 'document.xml' && path !== resourcePath) const opaqueEntriesPreserved = opaquePaths.every((path) => Buffer.from(initialFiles[path]).equals(Buffer.from(editedFiles[path] ?? new Uint8Array()))) const withoutEditedProperty = (inspection: typeof inspectedBefore) => inspection.objects.map((object) => ({ ...object, properties: object.name === 'IncludedProbe' ? object.properties.filter((candidate) => candidate.name !== 'File') : object.properties })) const semanticObjectsPreserved = isDeepStrictEqual(withoutEditedProperty(inspectedBefore), withoutEditedProperty(inspectedAfter)) const editedDocumentXml = new TextDecoder().decode(editedFiles['Document.xml']) const targetMarkedTouched = /]*name="IncludedProbe")(?=[^>]*Touched="1")[^>]*\/>/.test(editedDocumentXml) const webResource = property(inspectedAfter)?.includedFileResource const webOutputMatches = resourcePath === 'payload.bin' && webResource?.path === resourcePath && webResource.byteLength === targetAsset.byteLength && Buffer.from(editedFiles[resourcePath]).equals(Buffer.from(targetAsset)) const nativeAfter = runNative('verify', webPath, resavedPath) const resavedBytes = new Uint8Array(await readFile(resavedPath)) const resavedInspection = facade.project.fcstd.inspect(resavedBytes) const resavedProperty = property(resavedInspection) const resavedFiles = unzipSync(resavedBytes) await facade.project.resource.release(expectedValue.resourceHash) await facade.project.resource.release(targetValue.resourceHash) await facade.project.dispose() const nativeSnapshots = [nativeAfter.result.reopened, nativeAfter.result.resaved] const targetHash = sha256(targetAsset) const nativeOutputMatches = nativeSnapshots.every((snapshot: any) => snapshot.object.baseName === 'payload.bin' && snapshot.object.exists === true && snapshot.object.bytes === targetAsset.byteLength && snapshot.object.sha256 === targetHash && snapshot.object.writeBits === 0 && snapshot.object.underTransientDir === true) const objectSetPreserved = nativeSnapshots.every((snapshot: any) => isDeepStrictEqual(native.result.objectSet, snapshot.objectSet)) const resavedResourceMatches = resavedProperty?.resourcePath === resourcePath && resavedProperty.includedFileResource?.byteLength === targetAsset.byteLength && Buffer.from(resavedFiles[resourcePath]).equals(Buffer.from(targetAsset)) const zeroUnknownDrift = opaqueEntriesPreserved && semanticObjectsPreserved && targetMarkedTouched && webOutputMatches && nativeOutputMatches && objectSetPreserved && resavedResourceMatches const artifact = (path: string, bytes: Uint8Array) => ({ path, bytes: bytes.byteLength, sha256: sha256(bytes) }) const report = { schemaVersion: 1, status: zeroUnknownDrift ? 'pass' : 'fail', baselineId: 'freecad-1.1.1-property-fileincluded-roundtrip', freecadVersion: native.freecadVersion, gitCommit: native.gitCommit, propertyType: 'App::PropertyFileIncluded', assets: { initial: artifact('.cache/freecad/property-fileincluded-roundtrip/initial.bin', initialAsset), target: artifact('.cache/freecad/property-fileincluded-roundtrip/target.bin', targetAsset), }, archives: { nativeInitial: artifact('.cache/freecad/property-fileincluded-roundtrip/native-initial.FCStd', nativeBytes), webEdited: artifact('.cache/freecad/property-fileincluded-roundtrip/web-edited.FCStd', editedBytes), nativeResaved: artifact('.cache/freecad/property-fileincluded-roundtrip/native-resaved.FCStd', resavedBytes), }, nativeInitial: native.result, web: { before: property(inspectedBefore), after: property(inspectedAfter), resource: { path: resourcePath, pathPreserved: property(inspectedAfter)?.resourcePath === resourcePath, beforeBytes: initialFiles[resourcePath]?.byteLength ?? 0, afterBytes: editedFiles[resourcePath]?.byteLength ?? 0, beforeSha256: sha256(initialFiles[resourcePath]), afterSha256: sha256(editedFiles[resourcePath]), changed: !Buffer.from(initialFiles[resourcePath]).equals(Buffer.from(editedFiles[resourcePath])), }, targetMarkedTouched, webOutputMatches, semanticObjectsPreserved, opaquePathsPreserved: opaquePaths.length, opaqueEntriesPreserved, }, nativeAfter: nativeAfter.result, nativeResavedResource: resavedProperty?.includedFileResource, classification: { archiveName: resourcePath, initialSha256: sha256(initialAsset), targetSha256: targetHash, reopenedSha256: nativeAfter.result.reopened.object.sha256, resavedSha256: nativeAfter.result.resaved.object.sha256, nativeOutputMatches, objectSetPreserved, resavedResourceMatches, externalResourceBoundary: 'embedded FCStd ZIP entry restored to a read-only document transient file', unknownSemanticDrift: !zeroUnknownDrift, zeroUnknownDrift, }, } await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`) console.log(JSON.stringify({ status: report.status, output: 'config/freecad-property-fileincluded-roundtrip.json', resource: report.web.resource, classification: report.classification }, null, 2))