circuitjson-toolkit 1.0.16 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -0
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- package/src/workers/parser.worker.mjs +59 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { ArchiveEntryPath } from './ArchiveEntryPath.mjs'
|
|
2
|
+
import { ArchiveLimits } from './ArchiveLimits.mjs'
|
|
3
|
+
import { BinaryDataSnapshot } from './context/BinaryDataSnapshot.mjs'
|
|
4
|
+
import { ToolkitError } from './contracts/ToolkitError.mjs'
|
|
5
|
+
|
|
6
|
+
const CENTRAL_SIGNATURE = 0x02014b50
|
|
7
|
+
const END_SIGNATURE = 0x06054b50
|
|
8
|
+
const LOCAL_SIGNATURE = 0x04034b50
|
|
9
|
+
const END_FIXED_BYTES = 22
|
|
10
|
+
const CENTRAL_FIXED_BYTES = 46
|
|
11
|
+
const LOCAL_FIXED_BYTES = 30
|
|
12
|
+
const MAX_END_SEARCH_BYTES = 65_557
|
|
13
|
+
const ZIP64_UINT16 = 0xffff
|
|
14
|
+
const ZIP64_UINT32 = 0xffffffff
|
|
15
|
+
const DATA_DESCRIPTOR_FLAG = 0x0008
|
|
16
|
+
const TEXT_DECODER = new TextDecoder()
|
|
17
|
+
const TEXT_DECODER_DECODE = TextDecoder.prototype.decode
|
|
18
|
+
const CRC32_TABLE = new Uint32Array(256)
|
|
19
|
+
for (let index = 0; index < CRC32_TABLE.length; index += 1) {
|
|
20
|
+
let value = index
|
|
21
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
22
|
+
value = (value >>> 1) ^ (0xedb88320 & -(value & 1))
|
|
23
|
+
}
|
|
24
|
+
CRC32_TABLE[index] = value >>> 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Inspects ZIP central-directory metadata before any payload is inflated. */
|
|
28
|
+
export class ZipArchiveInspector {
|
|
29
|
+
/**
|
|
30
|
+
* Validates archive metadata against normalized shared limits.
|
|
31
|
+
* @param {ArrayBuffer | Uint8Array | DataView} data ZIP bytes.
|
|
32
|
+
* @param {{ archiveName?: string, archiveDepth?: number, baseEntryCount?: number, baseTotalBytes?: number, limits?: Record<string, number> }} [options] Inspection options.
|
|
33
|
+
* @returns {{ schema: string, archiveName: string, archiveDepth: number, entryCount: number, totalCompressedBytes: number, totalUncompressedBytes: number, entries: ReadonlyArray<Readonly<Record<string, any>>> }} Frozen preflight report.
|
|
34
|
+
*/
|
|
35
|
+
static inspect(data, options = {}) {
|
|
36
|
+
const normalized = ZipArchiveInspector.#options(options)
|
|
37
|
+
const bytes = ZipArchiveInspector.#bytes(data)
|
|
38
|
+
ZipArchiveInspector.#assertLimit(
|
|
39
|
+
'maxArchiveDepth',
|
|
40
|
+
normalized.limits.maxArchiveDepth,
|
|
41
|
+
normalized.archiveDepth,
|
|
42
|
+
normalized.archiveName
|
|
43
|
+
)
|
|
44
|
+
const end = ZipArchiveInspector.#endRecord(bytes)
|
|
45
|
+
const view = new DataView(
|
|
46
|
+
bytes.buffer,
|
|
47
|
+
bytes.byteOffset,
|
|
48
|
+
bytes.byteLength
|
|
49
|
+
)
|
|
50
|
+
const entries = ZipArchiveInspector.#centralEntries(
|
|
51
|
+
bytes,
|
|
52
|
+
view,
|
|
53
|
+
end,
|
|
54
|
+
normalized
|
|
55
|
+
)
|
|
56
|
+
const totalCompressedBytes = entries.reduce(
|
|
57
|
+
(total, entry) => total + entry.compressedSize,
|
|
58
|
+
0
|
|
59
|
+
)
|
|
60
|
+
const totalUncompressedBytes = entries.reduce(
|
|
61
|
+
(total, entry) => total + entry.uncompressedSize,
|
|
62
|
+
0
|
|
63
|
+
)
|
|
64
|
+
ZipArchiveInspector.#assertLimit(
|
|
65
|
+
'maxEntries',
|
|
66
|
+
normalized.limits.maxEntries,
|
|
67
|
+
normalized.baseEntryCount + entries.length,
|
|
68
|
+
normalized.archiveName
|
|
69
|
+
)
|
|
70
|
+
ZipArchiveInspector.#assertLimit(
|
|
71
|
+
'maxTotalBytes',
|
|
72
|
+
normalized.limits.maxTotalBytes,
|
|
73
|
+
normalized.baseTotalBytes + totalUncompressedBytes,
|
|
74
|
+
normalized.archiveName
|
|
75
|
+
)
|
|
76
|
+
const ratio =
|
|
77
|
+
totalCompressedBytes === 0
|
|
78
|
+
? totalUncompressedBytes === 0
|
|
79
|
+
? 0
|
|
80
|
+
: Number.POSITIVE_INFINITY
|
|
81
|
+
: totalUncompressedBytes / totalCompressedBytes
|
|
82
|
+
ZipArchiveInspector.#assertLimit(
|
|
83
|
+
'maxCompressionRatio',
|
|
84
|
+
normalized.limits.maxCompressionRatio,
|
|
85
|
+
ratio,
|
|
86
|
+
normalized.archiveName
|
|
87
|
+
)
|
|
88
|
+
const uniqueNames = ArchiveEntryPath.unique(
|
|
89
|
+
entries
|
|
90
|
+
.filter((entry) => !entry.directory)
|
|
91
|
+
.map((entry) => entry.name)
|
|
92
|
+
)
|
|
93
|
+
let fileIndex = 0
|
|
94
|
+
const frozenEntries = entries.map((entry) =>
|
|
95
|
+
Object.freeze({
|
|
96
|
+
...entry,
|
|
97
|
+
name: entry.directory ? entry.name : uniqueNames[fileIndex++]
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
schema: 'ecad-toolkit.zip-preflight.v1',
|
|
102
|
+
archiveName: normalized.archiveName,
|
|
103
|
+
archiveDepth: normalized.archiveDepth,
|
|
104
|
+
entryCount: entries.length,
|
|
105
|
+
totalCompressedBytes,
|
|
106
|
+
totalUncompressedBytes,
|
|
107
|
+
entries: Object.freeze(frozenEntries)
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verifies bytes produced by an inflater against inspected ZIP metadata.
|
|
113
|
+
* @param {{ name: string, uncompressedSize: number, crc32: number }} entry Frozen entry from `inspect()`.
|
|
114
|
+
* @param {ArrayBuffer | Uint8Array | DataView} data Extracted entry bytes.
|
|
115
|
+
* @returns {true} True when size and CRC32 match.
|
|
116
|
+
*/
|
|
117
|
+
static verifyExtractedBytes(entry, data) {
|
|
118
|
+
const expected = ZipArchiveInspector.#integrityEntry(entry)
|
|
119
|
+
const bytes = ZipArchiveInspector.#bytes(data)
|
|
120
|
+
if (bytes.byteLength !== expected.uncompressedSize) {
|
|
121
|
+
throw ZipArchiveInspector.#integrityInvalid(
|
|
122
|
+
'ZIP extracted byte length differs from its central directory.',
|
|
123
|
+
expected,
|
|
124
|
+
{ actualSize: bytes.byteLength }
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const actualCrc32 = ZipArchiveInspector.#crc32(bytes)
|
|
128
|
+
if (actualCrc32 !== expected.crc32) {
|
|
129
|
+
throw ZipArchiveInspector.#integrityInvalid(
|
|
130
|
+
'ZIP extracted bytes fail CRC32 verification.',
|
|
131
|
+
expected,
|
|
132
|
+
{ actualCrc32 }
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads and validates every central-directory record.
|
|
140
|
+
* @param {Uint8Array} bytes Archive bytes.
|
|
141
|
+
* @param {DataView} view Archive view.
|
|
142
|
+
* @param {{ entryCount: number, centralOffset: number, centralSize: number, offset: number }} end End record.
|
|
143
|
+
* @param {Record<string, any>} options Normalized options.
|
|
144
|
+
* @returns {Record<string, any>[]} Entry metadata.
|
|
145
|
+
*/
|
|
146
|
+
static #centralEntries(bytes, view, end, options) {
|
|
147
|
+
ZipArchiveInspector.#assertLimit(
|
|
148
|
+
'maxEntries',
|
|
149
|
+
options.limits.maxEntries,
|
|
150
|
+
options.baseEntryCount + end.entryCount,
|
|
151
|
+
options.archiveName
|
|
152
|
+
)
|
|
153
|
+
const centralEnd = end.centralOffset + end.centralSize
|
|
154
|
+
if (
|
|
155
|
+
end.centralOffset < 0 ||
|
|
156
|
+
end.centralSize < 0 ||
|
|
157
|
+
centralEnd > end.offset ||
|
|
158
|
+
centralEnd > bytes.byteLength
|
|
159
|
+
) {
|
|
160
|
+
throw ZipArchiveInspector.#invalid(
|
|
161
|
+
'ZIP central directory points outside the archive.',
|
|
162
|
+
options.archiveName
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
const entries = []
|
|
166
|
+
let offset = end.centralOffset
|
|
167
|
+
for (let index = 0; index < end.entryCount; index += 1) {
|
|
168
|
+
if (
|
|
169
|
+
offset + CENTRAL_FIXED_BYTES > centralEnd ||
|
|
170
|
+
view.getUint32(offset, true) !== CENTRAL_SIGNATURE
|
|
171
|
+
) {
|
|
172
|
+
throw ZipArchiveInspector.#invalid(
|
|
173
|
+
'ZIP central directory entry is malformed.',
|
|
174
|
+
options.archiveName
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
const flags = view.getUint16(offset + 8, true)
|
|
178
|
+
const method = view.getUint16(offset + 10, true)
|
|
179
|
+
const crc32 = view.getUint32(offset + 16, true)
|
|
180
|
+
const compressedSize = view.getUint32(offset + 20, true)
|
|
181
|
+
const uncompressedSize = view.getUint32(offset + 24, true)
|
|
182
|
+
const nameLength = view.getUint16(offset + 28, true)
|
|
183
|
+
const extraLength = view.getUint16(offset + 30, true)
|
|
184
|
+
const commentLength = view.getUint16(offset + 32, true)
|
|
185
|
+
const diskStart = view.getUint16(offset + 34, true)
|
|
186
|
+
const localOffset = view.getUint32(offset + 42, true)
|
|
187
|
+
if (
|
|
188
|
+
compressedSize === ZIP64_UINT32 ||
|
|
189
|
+
uncompressedSize === ZIP64_UINT32 ||
|
|
190
|
+
localOffset === ZIP64_UINT32 ||
|
|
191
|
+
diskStart === ZIP64_UINT16
|
|
192
|
+
) {
|
|
193
|
+
throw ZipArchiveInspector.#unsupported(
|
|
194
|
+
'ZIP64 archives are not supported by this bounded loader.',
|
|
195
|
+
options.archiveName
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (diskStart !== 0) {
|
|
199
|
+
throw ZipArchiveInspector.#unsupported(
|
|
200
|
+
'Multi-disk ZIP archives are not supported.',
|
|
201
|
+
options.archiveName
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
if ((flags & 1) !== 0) {
|
|
205
|
+
throw ZipArchiveInspector.#unsupported(
|
|
206
|
+
'Encrypted ZIP entries are not supported.',
|
|
207
|
+
options.archiveName
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
if (![0, 8].includes(method)) {
|
|
211
|
+
throw ZipArchiveInspector.#unsupported(
|
|
212
|
+
`ZIP compression method ${method} is not supported.`,
|
|
213
|
+
options.archiveName
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
const recordEnd =
|
|
217
|
+
offset +
|
|
218
|
+
CENTRAL_FIXED_BYTES +
|
|
219
|
+
nameLength +
|
|
220
|
+
extraLength +
|
|
221
|
+
commentLength
|
|
222
|
+
if (recordEnd > centralEnd) {
|
|
223
|
+
throw ZipArchiveInspector.#invalid(
|
|
224
|
+
'ZIP central directory entry exceeds its directory.',
|
|
225
|
+
options.archiveName
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
const rawNameBytes = bytes.subarray(
|
|
229
|
+
offset + CENTRAL_FIXED_BYTES,
|
|
230
|
+
offset + CENTRAL_FIXED_BYTES + nameLength
|
|
231
|
+
)
|
|
232
|
+
const rawName = Reflect.apply(TEXT_DECODER_DECODE, TEXT_DECODER, [
|
|
233
|
+
rawNameBytes
|
|
234
|
+
])
|
|
235
|
+
const directory = rawName.endsWith('/')
|
|
236
|
+
const path = directory ? rawName.slice(0, -1) : rawName
|
|
237
|
+
const name = ArchiveEntryPath.normalize(path)
|
|
238
|
+
ZipArchiveInspector.#assertLimit(
|
|
239
|
+
'maxEntryBytes',
|
|
240
|
+
options.limits.maxEntryBytes,
|
|
241
|
+
uncompressedSize,
|
|
242
|
+
name
|
|
243
|
+
)
|
|
244
|
+
const payloadOffset = ZipArchiveInspector.#localRecord(
|
|
245
|
+
bytes,
|
|
246
|
+
view,
|
|
247
|
+
end.centralOffset,
|
|
248
|
+
localOffset,
|
|
249
|
+
rawNameBytes,
|
|
250
|
+
compressedSize,
|
|
251
|
+
uncompressedSize,
|
|
252
|
+
crc32,
|
|
253
|
+
flags,
|
|
254
|
+
method,
|
|
255
|
+
options.archiveName
|
|
256
|
+
)
|
|
257
|
+
entries.push({
|
|
258
|
+
name,
|
|
259
|
+
directory,
|
|
260
|
+
flags,
|
|
261
|
+
method,
|
|
262
|
+
crc32,
|
|
263
|
+
compressedSize,
|
|
264
|
+
uncompressedSize,
|
|
265
|
+
localOffset,
|
|
266
|
+
payloadOffset
|
|
267
|
+
})
|
|
268
|
+
offset = recordEnd
|
|
269
|
+
}
|
|
270
|
+
if (offset !== centralEnd) {
|
|
271
|
+
throw ZipArchiveInspector.#invalid(
|
|
272
|
+
'ZIP central directory size is inconsistent.',
|
|
273
|
+
options.archiveName
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
return entries
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Validates the local record and compressed payload window.
|
|
281
|
+
* @param {Uint8Array} bytes Archive bytes.
|
|
282
|
+
* @param {DataView} view Archive view.
|
|
283
|
+
* @param {number} centralOffset Central-directory offset.
|
|
284
|
+
* @param {number} offset Local record offset.
|
|
285
|
+
* @param {Uint8Array} centralNameBytes Central-directory filename bytes.
|
|
286
|
+
* @param {number} compressedSize Compressed payload bytes.
|
|
287
|
+
* @param {number} uncompressedSize Extracted payload bytes.
|
|
288
|
+
* @param {number} crc32 Expected payload CRC32.
|
|
289
|
+
* @param {number} flags Central flags.
|
|
290
|
+
* @param {number} method Central compression method.
|
|
291
|
+
* @param {string} source Archive name.
|
|
292
|
+
* @returns {number} Compressed payload offset.
|
|
293
|
+
*/
|
|
294
|
+
static #localRecord(
|
|
295
|
+
bytes,
|
|
296
|
+
view,
|
|
297
|
+
centralOffset,
|
|
298
|
+
offset,
|
|
299
|
+
centralNameBytes,
|
|
300
|
+
compressedSize,
|
|
301
|
+
uncompressedSize,
|
|
302
|
+
crc32,
|
|
303
|
+
flags,
|
|
304
|
+
method,
|
|
305
|
+
source
|
|
306
|
+
) {
|
|
307
|
+
if (
|
|
308
|
+
offset + LOCAL_FIXED_BYTES > centralOffset ||
|
|
309
|
+
view.getUint32(offset, true) !== LOCAL_SIGNATURE
|
|
310
|
+
) {
|
|
311
|
+
throw ZipArchiveInspector.#invalid(
|
|
312
|
+
'ZIP local entry is malformed.',
|
|
313
|
+
source
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
const localFlags = view.getUint16(offset + 6, true)
|
|
317
|
+
const localMethod = view.getUint16(offset + 8, true)
|
|
318
|
+
const localCrc32 = view.getUint32(offset + 14, true)
|
|
319
|
+
const localCompressedSize = view.getUint32(offset + 18, true)
|
|
320
|
+
const localUncompressedSize = view.getUint32(offset + 22, true)
|
|
321
|
+
const nameLength = view.getUint16(offset + 26, true)
|
|
322
|
+
const extraLength = view.getUint16(offset + 28, true)
|
|
323
|
+
const nameOffset = offset + LOCAL_FIXED_BYTES
|
|
324
|
+
const payloadOffset = nameOffset + nameLength + extraLength
|
|
325
|
+
let localNameMatches = nameLength === centralNameBytes.byteLength
|
|
326
|
+
for (
|
|
327
|
+
let index = 0;
|
|
328
|
+
localNameMatches && index < nameLength;
|
|
329
|
+
index += 1
|
|
330
|
+
) {
|
|
331
|
+
localNameMatches =
|
|
332
|
+
bytes[nameOffset + index] === centralNameBytes[index]
|
|
333
|
+
}
|
|
334
|
+
const usesDataDescriptor = (flags & DATA_DESCRIPTOR_FLAG) !== 0
|
|
335
|
+
const descriptorMetadataValid =
|
|
336
|
+
usesDataDescriptor &&
|
|
337
|
+
[
|
|
338
|
+
[localCrc32, crc32],
|
|
339
|
+
[localCompressedSize, compressedSize],
|
|
340
|
+
[localUncompressedSize, uncompressedSize]
|
|
341
|
+
].every(([local, central]) => local === 0 || local === central)
|
|
342
|
+
const fixedMetadataValid =
|
|
343
|
+
!usesDataDescriptor &&
|
|
344
|
+
localCrc32 === crc32 &&
|
|
345
|
+
localCompressedSize === compressedSize &&
|
|
346
|
+
localUncompressedSize === uncompressedSize
|
|
347
|
+
if (
|
|
348
|
+
localFlags !== flags ||
|
|
349
|
+
localMethod !== method ||
|
|
350
|
+
!localNameMatches ||
|
|
351
|
+
(!descriptorMetadataValid && !fixedMetadataValid) ||
|
|
352
|
+
payloadOffset + compressedSize > centralOffset
|
|
353
|
+
) {
|
|
354
|
+
throw ZipArchiveInspector.#invalid(
|
|
355
|
+
'ZIP local entry metadata is inconsistent.',
|
|
356
|
+
source
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
return payloadOffset
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Captures trusted integrity fields without invoking accessors.
|
|
364
|
+
* @param {unknown} entry Inspected entry candidate.
|
|
365
|
+
* @returns {{ name: string, uncompressedSize: number, crc32: number }}
|
|
366
|
+
*/
|
|
367
|
+
static #integrityEntry(entry) {
|
|
368
|
+
let prototype
|
|
369
|
+
let descriptors
|
|
370
|
+
try {
|
|
371
|
+
prototype = Object.getPrototypeOf(entry)
|
|
372
|
+
descriptors = Object.getOwnPropertyDescriptors(entry)
|
|
373
|
+
} catch {
|
|
374
|
+
throw ZipArchiveInspector.#invalid(
|
|
375
|
+
'ZIP integrity metadata must be a plain object.'
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
(prototype !== Object.prototype && prototype !== null) ||
|
|
380
|
+
Object.values(descriptors).some(
|
|
381
|
+
(descriptor) => !Object.hasOwn(descriptor, 'value')
|
|
382
|
+
)
|
|
383
|
+
) {
|
|
384
|
+
throw ZipArchiveInspector.#invalid(
|
|
385
|
+
'ZIP integrity metadata must contain plain data properties.'
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
const name = descriptors.name?.value
|
|
389
|
+
const uncompressedSize = descriptors.uncompressedSize?.value
|
|
390
|
+
const crc32 = descriptors.crc32?.value
|
|
391
|
+
if (
|
|
392
|
+
typeof name !== 'string' ||
|
|
393
|
+
!Number.isSafeInteger(uncompressedSize) ||
|
|
394
|
+
uncompressedSize < 0 ||
|
|
395
|
+
!Number.isInteger(crc32) ||
|
|
396
|
+
crc32 < 0 ||
|
|
397
|
+
crc32 > ZIP64_UINT32
|
|
398
|
+
) {
|
|
399
|
+
throw ZipArchiveInspector.#invalid(
|
|
400
|
+
'ZIP integrity metadata is incomplete or invalid.'
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
return { name, uncompressedSize, crc32 }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Calculates an unsigned CRC32 with one reusable lookup table.
|
|
408
|
+
* @param {Uint8Array} bytes Source bytes.
|
|
409
|
+
* @returns {number} Unsigned CRC32.
|
|
410
|
+
*/
|
|
411
|
+
static #crc32(bytes) {
|
|
412
|
+
let crc = 0xffffffff
|
|
413
|
+
for (let index = 0; index < bytes.byteLength; index += 1) {
|
|
414
|
+
crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8)
|
|
415
|
+
}
|
|
416
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Locates and validates the single-disk end record.
|
|
421
|
+
* @param {Uint8Array} bytes Archive bytes.
|
|
422
|
+
* @returns {{ entryCount: number, centralOffset: number, centralSize: number, offset: number }} End record.
|
|
423
|
+
*/
|
|
424
|
+
static #endRecord(bytes) {
|
|
425
|
+
if (bytes.byteLength < END_FIXED_BYTES) {
|
|
426
|
+
throw ZipArchiveInspector.#invalid('ZIP archive is truncated.')
|
|
427
|
+
}
|
|
428
|
+
const view = new DataView(
|
|
429
|
+
bytes.buffer,
|
|
430
|
+
bytes.byteOffset,
|
|
431
|
+
bytes.byteLength
|
|
432
|
+
)
|
|
433
|
+
const start = Math.max(0, bytes.byteLength - MAX_END_SEARCH_BYTES)
|
|
434
|
+
for (
|
|
435
|
+
let offset = bytes.byteLength - END_FIXED_BYTES;
|
|
436
|
+
offset >= start;
|
|
437
|
+
offset -= 1
|
|
438
|
+
) {
|
|
439
|
+
if (view.getUint32(offset, true) !== END_SIGNATURE) continue
|
|
440
|
+
const commentLength = view.getUint16(offset + 20, true)
|
|
441
|
+
if (offset + END_FIXED_BYTES + commentLength !== bytes.byteLength) {
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
444
|
+
const disk = view.getUint16(offset + 4, true)
|
|
445
|
+
const centralDisk = view.getUint16(offset + 6, true)
|
|
446
|
+
const diskEntries = view.getUint16(offset + 8, true)
|
|
447
|
+
const entryCount = view.getUint16(offset + 10, true)
|
|
448
|
+
const centralSize = view.getUint32(offset + 12, true)
|
|
449
|
+
const centralOffset = view.getUint32(offset + 16, true)
|
|
450
|
+
if (
|
|
451
|
+
disk === ZIP64_UINT16 ||
|
|
452
|
+
centralDisk === ZIP64_UINT16 ||
|
|
453
|
+
entryCount === ZIP64_UINT16 ||
|
|
454
|
+
centralSize === ZIP64_UINT32 ||
|
|
455
|
+
centralOffset === ZIP64_UINT32
|
|
456
|
+
) {
|
|
457
|
+
throw ZipArchiveInspector.#unsupported(
|
|
458
|
+
'ZIP64 archives are not supported by this bounded loader.'
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
if (disk !== 0 || centralDisk !== 0 || diskEntries !== entryCount) {
|
|
462
|
+
throw ZipArchiveInspector.#unsupported(
|
|
463
|
+
'Multi-disk ZIP archives are not supported.'
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return { entryCount, centralOffset, centralSize, offset }
|
|
467
|
+
}
|
|
468
|
+
throw ZipArchiveInspector.#invalid(
|
|
469
|
+
'ZIP end-of-central-directory record was not found.'
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Returns an exact byte view, copying shared memory to prevent races.
|
|
475
|
+
* @param {unknown} data Binary archive data.
|
|
476
|
+
* @returns {Uint8Array} Archive bytes.
|
|
477
|
+
*/
|
|
478
|
+
static #bytes(data) {
|
|
479
|
+
const range = BinaryDataSnapshot.describe(data)
|
|
480
|
+
if (!range) {
|
|
481
|
+
throw ZipArchiveInspector.#invalid(
|
|
482
|
+
'ZIP archive data must be binary.'
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
if (
|
|
486
|
+
typeof SharedArrayBuffer === 'function' &&
|
|
487
|
+
range.buffer instanceof SharedArrayBuffer
|
|
488
|
+
) {
|
|
489
|
+
return BinaryDataSnapshot.copyBytes(data, range)
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
return new Uint8Array(
|
|
493
|
+
range.buffer,
|
|
494
|
+
range.byteOffset,
|
|
495
|
+
range.byteLength
|
|
496
|
+
)
|
|
497
|
+
} catch {
|
|
498
|
+
throw ZipArchiveInspector.#invalid(
|
|
499
|
+
'ZIP archive data changed during inspection.'
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Captures plain inspection options.
|
|
506
|
+
* @param {unknown} options Options candidate.
|
|
507
|
+
* @returns {Record<string, any>} Normalized options.
|
|
508
|
+
*/
|
|
509
|
+
static #options(options) {
|
|
510
|
+
let prototype
|
|
511
|
+
let descriptors
|
|
512
|
+
try {
|
|
513
|
+
prototype = Object.getPrototypeOf(options)
|
|
514
|
+
descriptors = Object.getOwnPropertyDescriptors(options)
|
|
515
|
+
} catch {
|
|
516
|
+
throw ZipArchiveInspector.#invalid(
|
|
517
|
+
'ZIP inspection options must be a plain object.'
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
if (
|
|
521
|
+
(prototype !== Object.prototype && prototype !== null) ||
|
|
522
|
+
Object.values(descriptors).some(
|
|
523
|
+
(descriptor) => !Object.hasOwn(descriptor, 'value')
|
|
524
|
+
)
|
|
525
|
+
) {
|
|
526
|
+
throw ZipArchiveInspector.#invalid(
|
|
527
|
+
'ZIP inspection options must contain plain data properties.'
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
const known = new Set([
|
|
531
|
+
'archiveName',
|
|
532
|
+
'archiveDepth',
|
|
533
|
+
'baseEntryCount',
|
|
534
|
+
'baseTotalBytes',
|
|
535
|
+
'limits'
|
|
536
|
+
])
|
|
537
|
+
if (Object.keys(descriptors).some((key) => !known.has(key))) {
|
|
538
|
+
throw ZipArchiveInspector.#invalid(
|
|
539
|
+
'ZIP inspection options contain an unknown field.'
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
archiveName: ZipArchiveInspector.#string(
|
|
544
|
+
descriptors.archiveName?.value,
|
|
545
|
+
''
|
|
546
|
+
),
|
|
547
|
+
archiveDepth: ZipArchiveInspector.#integer(
|
|
548
|
+
descriptors.archiveDepth?.value,
|
|
549
|
+
1,
|
|
550
|
+
'archiveDepth'
|
|
551
|
+
),
|
|
552
|
+
baseEntryCount: ZipArchiveInspector.#integer(
|
|
553
|
+
descriptors.baseEntryCount?.value,
|
|
554
|
+
0,
|
|
555
|
+
'baseEntryCount'
|
|
556
|
+
),
|
|
557
|
+
baseTotalBytes: ZipArchiveInspector.#integer(
|
|
558
|
+
descriptors.baseTotalBytes?.value,
|
|
559
|
+
0,
|
|
560
|
+
'baseTotalBytes'
|
|
561
|
+
),
|
|
562
|
+
limits: ArchiveLimits.normalize(descriptors.limits?.value || {})
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** @param {unknown} value Value. @param {string} fallback Fallback. @returns {string} String. */
|
|
567
|
+
static #string(value, fallback) {
|
|
568
|
+
if (value === undefined) return fallback
|
|
569
|
+
if (typeof value !== 'string') {
|
|
570
|
+
throw ZipArchiveInspector.#invalid(
|
|
571
|
+
'ZIP archiveName must be a string.'
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
return value
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** @param {unknown} value Value. @param {number} fallback Fallback. @param {string} name Field name. @returns {number} Integer. */
|
|
578
|
+
static #integer(value, fallback, name) {
|
|
579
|
+
if (value === undefined) return fallback
|
|
580
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
581
|
+
throw ZipArchiveInspector.#invalid(
|
|
582
|
+
`ZIP ${name} must be a nonnegative safe integer.`
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
return value
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** @param {string} name Limit name. @param {number} limit Limit. @param {number} actual Actual. @param {string} source Source. @returns {void} */
|
|
589
|
+
static #assertLimit(name, limit, actual, source) {
|
|
590
|
+
if (actual <= limit) return
|
|
591
|
+
throw new ToolkitError(`Archive ${name} exceeded.`, {
|
|
592
|
+
code: 'ERR_ARCHIVE_LIMIT_EXCEEDED',
|
|
593
|
+
category: 'validation',
|
|
594
|
+
format: 'archive',
|
|
595
|
+
source,
|
|
596
|
+
details: {
|
|
597
|
+
limit: name,
|
|
598
|
+
maximum: limit,
|
|
599
|
+
actual,
|
|
600
|
+
entryName: source
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** @param {string} message Message. @param {string} [source] Source. @returns {ToolkitError} Error. */
|
|
606
|
+
static #invalid(message, source = '') {
|
|
607
|
+
return new ToolkitError(message, {
|
|
608
|
+
code: 'ERR_ARCHIVE_INVALID',
|
|
609
|
+
category: 'validation',
|
|
610
|
+
format: 'archive',
|
|
611
|
+
source
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Builds an archive-integrity error with exact expected and actual fields.
|
|
617
|
+
* @param {string} message Error message.
|
|
618
|
+
* @param {{ name: string, uncompressedSize: number, crc32: number }} expected Expected entry metadata.
|
|
619
|
+
* @param {Record<string, number>} actual Actual integrity fields.
|
|
620
|
+
* @returns {ToolkitError}
|
|
621
|
+
*/
|
|
622
|
+
static #integrityInvalid(message, expected, actual) {
|
|
623
|
+
return new ToolkitError(message, {
|
|
624
|
+
code: 'ERR_ARCHIVE_INVALID',
|
|
625
|
+
category: 'validation',
|
|
626
|
+
format: 'archive',
|
|
627
|
+
source: expected.name,
|
|
628
|
+
details: {
|
|
629
|
+
entryName: expected.name,
|
|
630
|
+
expectedSize: expected.uncompressedSize,
|
|
631
|
+
expectedCrc32: expected.crc32,
|
|
632
|
+
...actual
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** @param {string} message Message. @param {string} [source] Source. @returns {ToolkitError} Error. */
|
|
638
|
+
static #unsupported(message, source = '') {
|
|
639
|
+
return new ToolkitError(message, {
|
|
640
|
+
code: 'ERR_ARCHIVE_UNSUPPORTED',
|
|
641
|
+
category: 'unsupported',
|
|
642
|
+
format: 'archive',
|
|
643
|
+
source
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
Object.freeze(ZipArchiveInspector.prototype)
|
|
649
|
+
Object.freeze(ZipArchiveInspector)
|