@longsightgroup/qti3-core 0.10.6 → 0.11.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.
Files changed (204) hide show
  1. package/README.md +126 -0
  2. package/dist/adaptive-turn-materializer.js +4 -5
  3. package/dist/adaptive-turn-materializer.js.map +1 -1
  4. package/dist/adaptive-turn.d.ts.map +1 -1
  5. package/dist/adaptive-turn.js +1 -0
  6. package/dist/adaptive-turn.js.map +1 -1
  7. package/dist/attempt-state.d.ts.map +1 -1
  8. package/dist/attempt-state.js +41 -1
  9. package/dist/attempt-state.js.map +1 -1
  10. package/dist/diagnostics.d.ts +13 -0
  11. package/dist/diagnostics.d.ts.map +1 -0
  12. package/dist/diagnostics.js +28 -0
  13. package/dist/diagnostics.js.map +1 -0
  14. package/dist/index.d.ts +3 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/item-submission-materialization.d.ts.map +1 -1
  19. package/dist/item-submission-materialization.js +2 -0
  20. package/dist/item-submission-materialization.js.map +1 -1
  21. package/dist/parser-processing.d.ts.map +1 -1
  22. package/dist/parser-processing.js +8 -18
  23. package/dist/parser-processing.js.map +1 -1
  24. package/dist/parser-values.d.ts.map +1 -1
  25. package/dist/parser-values.js +1 -3
  26. package/dist/parser-values.js.map +1 -1
  27. package/dist/parser.js +32 -0
  28. package/dist/parser.js.map +1 -1
  29. package/dist/processing-equality.d.ts +10 -0
  30. package/dist/processing-equality.d.ts.map +1 -0
  31. package/dist/processing-equality.js +78 -0
  32. package/dist/processing-equality.js.map +1 -0
  33. package/dist/processing-evaluator-comparison.d.ts.map +1 -1
  34. package/dist/processing-evaluator-comparison.js +14 -9
  35. package/dist/processing-evaluator-comparison.js.map +1 -1
  36. package/dist/processing-evaluator-numeric.d.ts.map +1 -1
  37. package/dist/processing-evaluator-numeric.js +2 -1
  38. package/dist/processing-evaluator-numeric.js.map +1 -1
  39. package/dist/processing-evaluator-string.d.ts.map +1 -1
  40. package/dist/processing-evaluator-string.js +26 -10
  41. package/dist/processing-evaluator-string.js.map +1 -1
  42. package/dist/processing-evaluator-variable.d.ts.map +1 -1
  43. package/dist/processing-evaluator-variable.js +9 -3
  44. package/dist/processing-evaluator-variable.js.map +1 -1
  45. package/dist/processing-evaluator.d.ts +5 -1
  46. package/dist/processing-evaluator.d.ts.map +1 -1
  47. package/dist/processing-evaluator.js +13 -4
  48. package/dist/processing-evaluator.js.map +1 -1
  49. package/dist/processing-expression-children.d.ts.map +1 -1
  50. package/dist/processing-expression-children.js +2 -3
  51. package/dist/processing-expression-children.js.map +1 -1
  52. package/dist/processing-expression-tags.d.ts +2 -1
  53. package/dist/processing-expression-tags.d.ts.map +1 -1
  54. package/dist/processing-expression-tags.js +2 -1
  55. package/dist/processing-expression-tags.js.map +1 -1
  56. package/dist/processing-mapping.d.ts.map +1 -1
  57. package/dist/processing-mapping.js +26 -16
  58. package/dist/processing-mapping.js.map +1 -1
  59. package/dist/processing-state.d.ts +2 -2
  60. package/dist/processing-state.d.ts.map +1 -1
  61. package/dist/processing-state.js +5 -2
  62. package/dist/processing-state.js.map +1 -1
  63. package/dist/processing-values.d.ts +1 -2
  64. package/dist/processing-values.d.ts.map +1 -1
  65. package/dist/processing-values.js +1 -3
  66. package/dist/processing-values.js.map +1 -1
  67. package/dist/processing-variables.d.ts.map +1 -1
  68. package/dist/processing-variables.js +10 -5
  69. package/dist/processing-variables.js.map +1 -1
  70. package/dist/qti-package-assets.d.ts.map +1 -1
  71. package/dist/qti-package-assets.js +16 -10
  72. package/dist/qti-package-assets.js.map +1 -1
  73. package/dist/qti-package-manifest.d.ts +1 -0
  74. package/dist/qti-package-manifest.d.ts.map +1 -1
  75. package/dist/qti-package-manifest.js +1 -0
  76. package/dist/qti-package-manifest.js.map +1 -1
  77. package/dist/qti-package-metadata.d.ts.map +1 -1
  78. package/dist/qti-package-metadata.js +11 -0
  79. package/dist/qti-package-metadata.js.map +1 -1
  80. package/dist/qti-package-plan.d.ts +1 -1
  81. package/dist/qti-package-plan.d.ts.map +1 -1
  82. package/dist/qti-package-types.d.ts +9 -0
  83. package/dist/qti-package-types.d.ts.map +1 -1
  84. package/dist/qti-package-xml.d.ts +6 -1
  85. package/dist/qti-package-xml.d.ts.map +1 -1
  86. package/dist/qti-package-xml.js +11 -6
  87. package/dist/qti-package-xml.js.map +1 -1
  88. package/dist/qti-package-zip.d.ts +13 -1
  89. package/dist/qti-package-zip.d.ts.map +1 -1
  90. package/dist/qti-package-zip.js +185 -54
  91. package/dist/qti-package-zip.js.map +1 -1
  92. package/dist/qti-package.d.ts +12 -5
  93. package/dist/qti-package.d.ts.map +1 -1
  94. package/dist/qti-package.js +63 -8
  95. package/dist/qti-package.js.map +1 -1
  96. package/dist/response-validation-policy.d.ts.map +1 -1
  97. package/dist/response-validation-policy.js +3 -1
  98. package/dist/response-validation-policy.js.map +1 -1
  99. package/dist/response-validation.d.ts.map +1 -1
  100. package/dist/response-validation.js +2 -1
  101. package/dist/response-validation.js.map +1 -1
  102. package/dist/serializer-processing-expressions.d.ts.map +1 -1
  103. package/dist/serializer-processing-expressions.js +2 -10
  104. package/dist/serializer-processing-expressions.js.map +1 -1
  105. package/dist/server-scoring.d.ts.map +1 -1
  106. package/dist/server-scoring.js +1 -0
  107. package/dist/server-scoring.js.map +1 -1
  108. package/dist/session-builtins.d.ts +16 -0
  109. package/dist/session-builtins.d.ts.map +1 -0
  110. package/dist/session-builtins.js +58 -0
  111. package/dist/session-builtins.js.map +1 -0
  112. package/dist/session.d.ts +5 -1
  113. package/dist/session.d.ts.map +1 -1
  114. package/dist/session.js +63 -11
  115. package/dist/session.js.map +1 -1
  116. package/dist/support-evidence.d.ts.map +1 -1
  117. package/dist/support-evidence.js +2 -0
  118. package/dist/support-evidence.js.map +1 -1
  119. package/dist/support.d.ts.map +1 -1
  120. package/dist/support.js +64 -23
  121. package/dist/support.js.map +1 -1
  122. package/dist/trusted-item-session.d.ts +4 -1
  123. package/dist/trusted-item-session.d.ts.map +1 -1
  124. package/dist/trusted-item-session.js +8 -3
  125. package/dist/trusted-item-session.js.map +1 -1
  126. package/dist/types.d.ts +21 -3
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/validation-declaration-entries.d.ts.map +1 -1
  129. package/dist/validation-declaration-entries.js +11 -1
  130. package/dist/validation-declaration-entries.js.map +1 -1
  131. package/dist/validation-item-children.d.ts +4 -0
  132. package/dist/validation-item-children.d.ts.map +1 -0
  133. package/dist/validation-item-children.js +45 -0
  134. package/dist/validation-item-children.js.map +1 -0
  135. package/dist/validation-processing.d.ts.map +1 -1
  136. package/dist/validation-processing.js +45 -12
  137. package/dist/validation-processing.js.map +1 -1
  138. package/dist/validation.d.ts.map +1 -1
  139. package/dist/validation.js +12 -0
  140. package/dist/validation.js.map +1 -1
  141. package/dist/value-guards.d.ts +4 -0
  142. package/dist/value-guards.d.ts.map +1 -0
  143. package/dist/value-guards.js +5 -0
  144. package/dist/value-guards.js.map +1 -0
  145. package/dist/xml-schema-regex-data.d.ts +48 -0
  146. package/dist/xml-schema-regex-data.d.ts.map +1 -0
  147. package/dist/xml-schema-regex-data.js +403 -0
  148. package/dist/xml-schema-regex-data.js.map +1 -0
  149. package/dist/xml-schema-regex-parser.d.ts +31 -0
  150. package/dist/xml-schema-regex-parser.d.ts.map +1 -0
  151. package/dist/xml-schema-regex-parser.js +245 -0
  152. package/dist/xml-schema-regex-parser.js.map +1 -0
  153. package/dist/xml-schema-regex.d.ts +14 -0
  154. package/dist/xml-schema-regex.d.ts.map +1 -0
  155. package/dist/xml-schema-regex.js +114 -0
  156. package/dist/xml-schema-regex.js.map +1 -0
  157. package/package.json +1 -1
  158. package/src/adaptive-turn-materializer.ts +4 -3
  159. package/src/adaptive-turn.ts +1 -0
  160. package/src/attempt-state.ts +54 -1
  161. package/src/diagnostics.ts +29 -0
  162. package/src/index.ts +9 -0
  163. package/src/item-submission-materialization.ts +2 -0
  164. package/src/parser-processing.ts +15 -18
  165. package/src/parser-values.ts +1 -5
  166. package/src/parser.ts +34 -0
  167. package/src/processing-equality.ts +104 -0
  168. package/src/processing-evaluator-comparison.ts +19 -14
  169. package/src/processing-evaluator-numeric.ts +2 -6
  170. package/src/processing-evaluator-string.ts +25 -18
  171. package/src/processing-evaluator-variable.ts +8 -3
  172. package/src/processing-evaluator.ts +20 -7
  173. package/src/processing-expression-children.ts +2 -3
  174. package/src/processing-expression-tags.ts +2 -1
  175. package/src/processing-mapping.ts +31 -25
  176. package/src/processing-state.ts +6 -1
  177. package/src/processing-values.ts +2 -11
  178. package/src/processing-variables.ts +9 -7
  179. package/src/qti-package-assets.ts +20 -13
  180. package/src/qti-package-manifest.ts +1 -0
  181. package/src/qti-package-metadata.ts +11 -0
  182. package/src/qti-package-plan.ts +1 -1
  183. package/src/qti-package-types.ts +10 -0
  184. package/src/qti-package-xml.ts +11 -6
  185. package/src/qti-package-zip.ts +330 -97
  186. package/src/qti-package.ts +106 -7
  187. package/src/response-validation-policy.ts +5 -1
  188. package/src/response-validation.ts +2 -1
  189. package/src/serializer-processing-expressions.ts +8 -25
  190. package/src/server-scoring.ts +1 -0
  191. package/src/session-builtins.ts +75 -0
  192. package/src/session.ts +64 -8
  193. package/src/support-evidence.ts +2 -0
  194. package/src/support.ts +84 -23
  195. package/src/trusted-item-session.ts +13 -2
  196. package/src/types.ts +30 -4
  197. package/src/validation-declaration-entries.ts +11 -1
  198. package/src/validation-item-children.ts +48 -0
  199. package/src/validation-processing.ts +47 -10
  200. package/src/validation.ts +14 -0
  201. package/src/value-guards.ts +6 -0
  202. package/src/xml-schema-regex-data.ts +404 -0
  203. package/src/xml-schema-regex-parser.ts +234 -0
  204. package/src/xml-schema-regex.ts +115 -0
@@ -20,6 +20,12 @@ export type QtiPackageInflateRaw = (
20
20
  context: QtiPackageInflateContext,
21
21
  ) => Uint8Array;
22
22
 
23
+ /** Async raw DEFLATE adapter; enforce the same output budget as the sync adapter. */
24
+ export type QtiPackageInflateRawAsync = (
25
+ bytes: Uint8Array,
26
+ context: QtiPackageInflateContext,
27
+ ) => Promise<Uint8Array>;
28
+
23
29
  /** Resource budgets applied before and after QTI package entry expansion. */
24
30
  export interface QtiPackageResourceLimits {
25
31
  readonly maxEntries: number;
@@ -43,20 +49,124 @@ export interface QtiPackageParseOptions {
43
49
  readonly limits?: Partial<QtiPackageResourceLimits> | undefined;
44
50
  }
45
51
 
52
+ /** Options for ZIP extraction with an asynchronous host inflater. */
53
+ export interface QtiPackageZipAsyncOptions {
54
+ readonly inflateRaw?: QtiPackageInflateRawAsync | undefined;
55
+ readonly limits?: Partial<QtiPackageResourceLimits> | undefined;
56
+ }
57
+
46
58
  /** Decoded ZIP entry from a QTI package. */
47
59
  export interface QtiPackageEntry {
48
60
  readonly path: string;
49
61
  readonly bytes: Uint8Array;
50
62
  }
51
63
 
52
- /** Read stored ZIP entries and optionally inflate raw DEFLATE entries through caller code. */
64
+ interface ZipEntry extends QtiPackageInflateContext {
65
+ readonly compressed: Uint8Array;
66
+ }
67
+
68
+ /** Read stored ZIP entries and optionally inflate raw DEFLATE entries through caller code.
69
+ * Entries are provisional: reject the archive if diagnostics contains an error.
70
+ */
53
71
  export function readQtiPackageZipEntries(
54
72
  bytes: Uint8Array,
55
73
  options: QtiPackageParseOptions,
56
74
  diagnostics: QtiDiagnostic[],
57
75
  ): QtiPackageEntry[] {
76
+ const entries: QtiPackageEntry[] = [];
77
+ for (const entry of inspectZipEntries(bytes, options.limits, diagnostics)) {
78
+ try {
79
+ let expanded = entry.compressed;
80
+ if (entry.compressionMethod !== 0) {
81
+ const inflateRaw = options.inflateRaw;
82
+ if (!inflateRaw) {
83
+ diagnoseMissingInflater(entry, diagnostics);
84
+ continue;
85
+ }
86
+ expanded = inflateRaw(entry.compressed, entry);
87
+ }
88
+ appendExpandedEntry(entries, entry, expanded, diagnostics);
89
+ } catch {
90
+ diagnoseInflateFailure(entry, diagnostics);
91
+ }
92
+ }
93
+ return entries;
94
+ }
95
+
96
+ /** Async counterpart with identical archive validation, limits, and diagnostics. */
97
+ export async function readQtiPackageZipEntriesAsync(
98
+ bytes: Uint8Array,
99
+ options: QtiPackageZipAsyncOptions,
100
+ diagnostics: QtiDiagnostic[],
101
+ ): Promise<QtiPackageEntry[]> {
102
+ const entries: QtiPackageEntry[] = [];
103
+ for (const entry of inspectZipEntries(bytes, options.limits, diagnostics)) {
104
+ try {
105
+ let expanded = entry.compressed;
106
+ if (entry.compressionMethod !== 0) {
107
+ const inflateRaw = options.inflateRaw;
108
+ if (!inflateRaw) {
109
+ diagnoseMissingInflater(entry, diagnostics);
110
+ continue;
111
+ }
112
+ expanded = await inflateRaw(entry.compressed, entry);
113
+ }
114
+ appendExpandedEntry(entries, entry, expanded, diagnostics);
115
+ } catch {
116
+ diagnoseInflateFailure(entry, diagnostics);
117
+ }
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ function diagnoseMissingInflater(entry: ZipEntry, diagnostics: QtiDiagnostic[]): void {
123
+ pushPackageDiagnostic(
124
+ diagnostics,
125
+ "package.zip.entry.compression.unsupported",
126
+ "error",
127
+ `ZIP entry ${entry.path} uses DEFLATE compression, but no inflateRaw option was provided.`,
128
+ entry.path,
129
+ );
130
+ }
131
+
132
+ function appendExpandedEntry(
133
+ entries: QtiPackageEntry[],
134
+ entry: ZipEntry,
135
+ expanded: Uint8Array,
136
+ diagnostics: QtiDiagnostic[],
137
+ ): void {
138
+ if (expanded.length === entry.uncompressedSize) {
139
+ entries.push({ path: entry.path, bytes: expanded });
140
+ } else {
141
+ pushPackageDiagnostic(
142
+ diagnostics,
143
+ "package.zip.entry.size",
144
+ "error",
145
+ `ZIP entry ${entry.path} expanded to ${expanded.length} bytes; expected ${entry.uncompressedSize}.`,
146
+ entry.path,
147
+ );
148
+ }
149
+ }
150
+
151
+ function diagnoseInflateFailure(entry: ZipEntry, diagnostics: QtiDiagnostic[]): void {
152
+ pushPackageDiagnostic(
153
+ diagnostics,
154
+ "package.zip.entry.inflate",
155
+ "error",
156
+ `ZIP entry ${entry.path} could not be inflated.`,
157
+ entry.path,
158
+ );
159
+ }
160
+
161
+ function inspectZipEntries(
162
+ bytes: Uint8Array,
163
+ overrides: QtiPackageParseOptions["limits"],
164
+ diagnostics: QtiDiagnostic[],
165
+ ): ZipEntry[] {
166
+ const limits = resolveResourceLimits(overrides, diagnostics);
167
+ if (!limits) return [];
58
168
  try {
59
- return readZipEntriesUnsafe(bytes, options, diagnostics);
169
+ return readZipDirectory(bytes, limits, diagnostics);
60
170
  } catch {
61
171
  pushPackageDiagnostic(
62
172
  diagnostics,
@@ -94,11 +204,11 @@ export function decodeUtf8(bytes: Uint8Array): string {
94
204
  return text;
95
205
  }
96
206
 
97
- function readZipEntriesUnsafe(
207
+ function readZipDirectory(
98
208
  bytes: Uint8Array,
99
- options: QtiPackageParseOptions,
209
+ limits: QtiPackageResourceLimits,
100
210
  diagnostics: QtiDiagnostic[],
101
- ): QtiPackageEntry[] {
211
+ ): ZipEntry[] {
102
212
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
103
213
  const eocdOffset = findEndOfCentralDirectory(view);
104
214
  if (eocdOffset < 0) {
@@ -111,7 +221,6 @@ function readZipEntriesUnsafe(
111
221
  return [];
112
222
  }
113
223
 
114
- const limits = resolveResourceLimits(options.limits);
115
224
  const entryCount = view.getUint16(eocdOffset + 10, true);
116
225
  if (entryCount > limits.maxEntries) {
117
226
  pushPackageDiagnostic(
@@ -122,12 +231,38 @@ function readZipEntriesUnsafe(
122
231
  );
123
232
  return [];
124
233
  }
234
+ if (
235
+ view.getUint16(eocdOffset + 4, true) !== 0 ||
236
+ view.getUint16(eocdOffset + 6, true) !== 0 ||
237
+ view.getUint16(eocdOffset + 8, true) !== entryCount ||
238
+ entryCount === 0xffff
239
+ ) {
240
+ pushPackageDiagnostic(
241
+ diagnostics,
242
+ "package.zip.format.unsupported",
243
+ "error",
244
+ "Multi-disk and ZIP64 packages are not supported.",
245
+ );
246
+ return [];
247
+ }
125
248
  let offset = view.getUint32(eocdOffset + 16, true);
249
+ const directoryStart = offset;
250
+ if (offset + view.getUint32(eocdOffset + 12, true) !== eocdOffset) {
251
+ pushPackageDiagnostic(
252
+ diagnostics,
253
+ "package.zip.centralDirectory.bounds",
254
+ "error",
255
+ "ZIP central directory bounds are invalid.",
256
+ );
257
+ return [];
258
+ }
259
+ const paths = new Set<string>();
260
+ const ranges: { start: number; end: number }[] = [];
126
261
  let totalUncompressedBytes = 0;
127
- const entries: QtiPackageEntry[] = [];
262
+ const entries: ZipEntry[] = [];
128
263
 
129
264
  for (let index = 0; index < entryCount; index += 1) {
130
- if (offset + 46 > view.byteLength) {
265
+ if (offset + 46 > eocdOffset) {
131
266
  pushPackageDiagnostic(
132
267
  diagnostics,
133
268
  "package.zip.centralDirectory.truncated",
@@ -147,6 +282,7 @@ function readZipEntriesUnsafe(
147
282
  break;
148
283
  }
149
284
 
285
+ const flags = view.getUint16(offset + 8, true);
150
286
  const compressionMethod = view.getUint16(offset + 10, true);
151
287
  const compressedSize = view.getUint32(offset + 20, true);
152
288
  const uncompressedSize = view.getUint32(offset + 24, true);
@@ -155,10 +291,11 @@ function readZipEntriesUnsafe(
155
291
  const nameLength = view.getUint16(offset + 28, true);
156
292
  const extraLength = view.getUint16(offset + 30, true);
157
293
  const commentLength = view.getUint16(offset + 32, true);
294
+ const disk = view.getUint16(offset + 34, true);
158
295
  const localHeaderOffset = view.getUint32(offset + 42, true);
159
296
  const nameStart = offset + 46;
160
297
  const nameEnd = nameStart + nameLength;
161
- if (nameEnd > view.byteLength) {
298
+ if (nameEnd + extraLength + commentLength > eocdOffset) {
162
299
  pushPackageDiagnostic(
163
300
  diagnostics,
164
301
  "package.zip.centralDirectory.name",
@@ -169,9 +306,52 @@ function readZipEntriesUnsafe(
169
306
  }
170
307
 
171
308
  const rawName = bytes.slice(nameStart, nameEnd);
172
- const decodedName = decodeUtf8(rawName).replaceAll("\\", "/");
309
+ const decodedName = decodeUtf8(rawName);
173
310
  offset += 46 + nameLength + extraLength + commentLength;
174
- if (decodedName.endsWith("/")) continue;
311
+ if (!isValidUtf8(rawName)) {
312
+ pushPackageDiagnostic(
313
+ diagnostics,
314
+ "package.zip.entry.name",
315
+ "error",
316
+ "ZIP entry name is not valid UTF-8.",
317
+ );
318
+ continue;
319
+ }
320
+ if (
321
+ disk !== 0 ||
322
+ compressedSize === 0xffffffff ||
323
+ uncompressedSize === 0xffffffff ||
324
+ localHeaderOffset === 0xffffffff
325
+ ) {
326
+ pushPackageDiagnostic(
327
+ diagnostics,
328
+ "package.zip.format.unsupported",
329
+ "error",
330
+ "Multi-disk and ZIP64 entries are not supported.",
331
+ decodedName,
332
+ );
333
+ continue;
334
+ }
335
+ if ((flags & ~0x080e) !== 0) {
336
+ pushPackageDiagnostic(
337
+ diagnostics,
338
+ "package.zip.entry.flags.unsupported",
339
+ "error",
340
+ "ZIP encryption or entry flags are unsupported.",
341
+ decodedName,
342
+ );
343
+ continue;
344
+ }
345
+ if (compressionMethod !== 0 && compressionMethod !== 8) {
346
+ pushPackageDiagnostic(
347
+ diagnostics,
348
+ "package.zip.entry.compression.unsupported",
349
+ "error",
350
+ `ZIP entry ${decodedName} uses unsupported compression method ${compressionMethod}.`,
351
+ decodedName,
352
+ );
353
+ continue;
354
+ }
175
355
 
176
356
  if (declaredExpandedSize > limits.maxEntryUncompressedBytes) {
177
357
  pushPackageDiagnostic(
@@ -204,10 +384,32 @@ function readZipEntriesUnsafe(
204
384
  }
205
385
  totalUncompressedBytes += declaredExpandedSize;
206
386
 
207
- const path = normalizePackagePath(decodedName, "ZIP entry", diagnostics);
208
- if (!path) continue;
387
+ const name = decodedName.endsWith("/") ? decodedName.slice(0, -1) : decodedName;
388
+ const path = normalizePackagePath(name, "ZIP entry", diagnostics);
389
+ if (path === undefined) continue;
390
+ if (!path || path !== name || name.includes("\\") || name.includes("\0")) {
391
+ pushPackageDiagnostic(
392
+ diagnostics,
393
+ "package.zip.entry.path",
394
+ "error",
395
+ "ZIP entry paths must be canonical and package-relative.",
396
+ decodedName,
397
+ );
398
+ continue;
399
+ }
400
+ if (paths.has(path)) {
401
+ pushPackageDiagnostic(
402
+ diagnostics,
403
+ "package.entry.duplicate",
404
+ "error",
405
+ `QTI package contains duplicate entry ${path}.`,
406
+ path,
407
+ );
408
+ continue;
409
+ }
410
+ paths.add(path);
209
411
 
210
- const content = zipEntryBytes(
412
+ const content = readCompressedEntry(
211
413
  bytes,
212
414
  view,
213
415
  {
@@ -216,18 +418,61 @@ function readZipEntriesUnsafe(
216
418
  uncompressedSize,
217
419
  compressionMethod,
218
420
  localHeaderOffset,
421
+ flags,
422
+ rawName,
219
423
  },
220
- options,
221
- limits,
424
+ directoryStart,
425
+ ranges,
222
426
  diagnostics,
223
427
  );
224
- if (content) entries.push({ path, bytes: content });
428
+ if (!content) continue;
429
+ if (decodedName.endsWith("/")) {
430
+ if (uncompressedSize !== 0 || (compressionMethod === 0 && compressedSize !== 0))
431
+ pushPackageDiagnostic(
432
+ diagnostics,
433
+ "package.zip.directory.content",
434
+ "error",
435
+ "ZIP directory has unexpected content.",
436
+ path,
437
+ );
438
+ continue;
439
+ }
440
+ entries.push({
441
+ path,
442
+ compressed: content,
443
+ compressedSize,
444
+ uncompressedSize,
445
+ compressionMethod,
446
+ maxOutputLength: Math.max(1, uncompressedSize),
447
+ });
225
448
  }
226
449
 
450
+ if (offset !== eocdOffset) {
451
+ pushPackageDiagnostic(
452
+ diagnostics,
453
+ "package.zip.centralDirectory.count",
454
+ "error",
455
+ "ZIP entry count does not match the central directory.",
456
+ );
457
+ }
458
+ ranges.sort((left, right) => left.start - right.start);
459
+ for (let index = 1; index < ranges.length; index++) {
460
+ const previous = ranges[index - 1];
461
+ const current = ranges[index];
462
+ if (previous && current && previous.end > current.start) {
463
+ pushPackageDiagnostic(
464
+ diagnostics,
465
+ "package.zip.entry.overlap",
466
+ "error",
467
+ "ZIP entry bodies overlap.",
468
+ );
469
+ return [];
470
+ }
471
+ }
227
472
  return entries;
228
473
  }
229
474
 
230
- function zipEntryBytes(
475
+ function readCompressedEntry(
231
476
  bytes: Uint8Array,
232
477
  view: DataView,
233
478
  entry: {
@@ -236,12 +481,14 @@ function zipEntryBytes(
236
481
  readonly uncompressedSize: number;
237
482
  readonly compressionMethod: number;
238
483
  readonly localHeaderOffset: number;
484
+ readonly flags: number;
485
+ readonly rawName: Uint8Array;
239
486
  },
240
- options: QtiPackageParseOptions,
241
- limits: QtiPackageResourceLimits,
487
+ directoryStart: number,
488
+ ranges: { start: number; end: number }[],
242
489
  diagnostics: QtiDiagnostic[],
243
490
  ): Uint8Array | undefined {
244
- if (entry.localHeaderOffset + 30 > view.byteLength) {
491
+ if (entry.localHeaderOffset + 30 > directoryStart) {
245
492
  pushPackageDiagnostic(
246
493
  diagnostics,
247
494
  "package.zip.localHeader.truncated",
@@ -267,7 +514,7 @@ function zipEntryBytes(
267
514
  const extraLength = view.getUint16(entry.localHeaderOffset + 28, true);
268
515
  const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
269
516
  const dataEnd = dataOffset + entry.compressedSize;
270
- if (dataEnd > view.byteLength) {
517
+ if (dataEnd > directoryStart) {
271
518
  pushPackageDiagnostic(
272
519
  diagnostics,
273
520
  "package.zip.entry.truncated",
@@ -278,59 +525,37 @@ function zipEntryBytes(
278
525
  return undefined;
279
526
  }
280
527
 
281
- const compressed = bytes.slice(dataOffset, dataEnd);
282
- if (entry.compressionMethod === 0) {
283
- return validateExpandedSize(compressed, entry, limits, diagnostics);
284
- }
285
-
286
- if (entry.compressionMethod === 8) {
287
- const inflateRaw = options.inflateRaw;
288
- if (!inflateRaw) {
289
- pushPackageDiagnostic(
290
- diagnostics,
291
- "package.zip.entry.compression.unsupported",
292
- "error",
293
- `ZIP entry ${entry.path} uses DEFLATE compression, but no inflateRaw option was provided.`,
294
- entry.path,
295
- );
296
- return undefined;
297
- }
298
-
299
- try {
300
- const expanded = inflateRaw(compressed, {
301
- path: entry.path,
302
- compressedSize: entry.compressedSize,
303
- uncompressedSize: entry.uncompressedSize,
304
- compressionMethod: entry.compressionMethod,
305
- maxOutputLength: Math.max(1, entry.uncompressedSize),
306
- });
307
- return validateExpandedSize(expanded, entry, limits, diagnostics);
308
- } catch {
309
- pushPackageDiagnostic(
310
- diagnostics,
311
- "package.zip.entry.inflate",
312
- "error",
313
- `ZIP entry ${entry.path} could not be inflated.`,
314
- entry.path,
315
- );
316
- return undefined;
317
- }
318
- }
319
-
320
- pushPackageDiagnostic(
321
- diagnostics,
322
- "package.zip.entry.compression.unsupported",
323
- "error",
324
- `ZIP entry ${entry.path} uses unsupported compression method ${entry.compressionMethod}.`,
325
- entry.path,
528
+ const localName = bytes.subarray(
529
+ entry.localHeaderOffset + 30,
530
+ entry.localHeaderOffset + 30 + nameLength,
326
531
  );
327
- return undefined;
532
+ if (
533
+ view.getUint16(entry.localHeaderOffset + 6, true) !== entry.flags ||
534
+ view.getUint16(entry.localHeaderOffset + 8, true) !== entry.compressionMethod ||
535
+ localName.length !== entry.rawName.length ||
536
+ localName.some((value, index) => value !== entry.rawName[index]) ||
537
+ (!(entry.flags & 8) &&
538
+ (view.getUint32(entry.localHeaderOffset + 18, true) !== entry.compressedSize ||
539
+ view.getUint32(entry.localHeaderOffset + 22, true) !== entry.uncompressedSize))
540
+ ) {
541
+ pushPackageDiagnostic(
542
+ diagnostics,
543
+ "package.zip.localHeader.mismatch",
544
+ "error",
545
+ `ZIP local and central entries for ${entry.path} do not match.`,
546
+ entry.path,
547
+ );
548
+ return undefined;
549
+ }
550
+ ranges.push({ start: entry.localHeaderOffset, end: dataEnd });
551
+ return bytes.slice(dataOffset, dataEnd);
328
552
  }
329
553
 
330
- function resolveResourceLimits(
554
+ export function resolveResourceLimits(
331
555
  overrides: QtiPackageParseOptions["limits"],
332
- ): QtiPackageResourceLimits {
333
- return {
556
+ diagnostics: QtiDiagnostic[],
557
+ ): QtiPackageResourceLimits | undefined {
558
+ const limits = {
334
559
  maxEntries: overrides?.maxEntries ?? DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxEntries,
335
560
  maxEntryUncompressedBytes:
336
561
  overrides?.maxEntryUncompressedBytes ??
@@ -341,6 +566,20 @@ function resolveResourceLimits(
341
566
  maxCompressionRatio:
342
567
  overrides?.maxCompressionRatio ?? DEFAULT_QTI_PACKAGE_RESOURCE_LIMITS.maxCompressionRatio,
343
568
  };
569
+ if (
570
+ Object.values(limits).some(
571
+ (limit) => limit !== Infinity && (!Number.isSafeInteger(limit) || limit <= 0),
572
+ )
573
+ ) {
574
+ pushPackageDiagnostic(
575
+ diagnostics,
576
+ "package.limit.invalid",
577
+ "error",
578
+ "Package resource limits must be positive safe integers or Infinity.",
579
+ );
580
+ return undefined;
581
+ }
582
+ return limits;
344
583
  }
345
584
 
346
585
  function exceedsCompressionRatio(
@@ -354,35 +593,14 @@ function exceedsCompressionRatio(
354
593
  return uncompressedSize / compressedSize > limits.maxCompressionRatio;
355
594
  }
356
595
 
357
- function validateExpandedSize(
358
- expanded: Uint8Array,
359
- entry: {
360
- readonly path: string;
361
- readonly uncompressedSize: number;
362
- },
363
- limits: QtiPackageResourceLimits,
364
- diagnostics: QtiDiagnostic[],
365
- ): Uint8Array | undefined {
366
- if (
367
- expanded.length === entry.uncompressedSize &&
368
- expanded.length <= limits.maxEntryUncompressedBytes
369
- ) {
370
- return expanded;
371
- }
372
- pushPackageDiagnostic(
373
- diagnostics,
374
- "package.zip.entry.size",
375
- "error",
376
- `ZIP entry ${entry.path} expanded to ${expanded.length} bytes; expected ${entry.uncompressedSize}.`,
377
- entry.path,
378
- );
379
- return undefined;
380
- }
381
-
382
596
  function findEndOfCentralDirectory(view: DataView): number {
383
597
  const minimumOffset = Math.max(0, view.byteLength - 65557);
384
598
  for (let offset = view.byteLength - 22; offset >= minimumOffset; offset -= 1) {
385
- if (view.getUint32(offset, true) === 0x06054b50) return offset;
599
+ if (
600
+ view.getUint32(offset, true) === 0x06054b50 &&
601
+ offset + 22 + view.getUint16(offset + 20, true) === view.byteLength
602
+ )
603
+ return offset;
386
604
  }
387
605
  return -1;
388
606
  }
@@ -448,3 +666,18 @@ function decodeUtf8CodePoint(
448
666
  function isUtf8Continuation(value: number | undefined): value is number {
449
667
  return value !== undefined && value >= 0x80 && value <= 0xbf;
450
668
  }
669
+
670
+ function isValidUtf8(bytes: Uint8Array): boolean {
671
+ for (let index = 0; index < bytes.length; ) {
672
+ const byte = bytes[index];
673
+ if (byte === undefined) return false;
674
+ if (byte < 0x80) {
675
+ index++;
676
+ continue;
677
+ }
678
+ const decoded = decodeUtf8CodePoint(bytes, index);
679
+ if (decoded.length === 1) return false;
680
+ index += decoded.length;
681
+ }
682
+ return true;
683
+ }