@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.
- package/README.md +126 -0
- package/dist/adaptive-turn-materializer.js +4 -5
- package/dist/adaptive-turn-materializer.js.map +1 -1
- package/dist/adaptive-turn.d.ts.map +1 -1
- package/dist/adaptive-turn.js +1 -0
- package/dist/adaptive-turn.js.map +1 -1
- package/dist/attempt-state.d.ts.map +1 -1
- package/dist/attempt-state.js +41 -1
- package/dist/attempt-state.js.map +1 -1
- package/dist/diagnostics.d.ts +13 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +28 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/item-submission-materialization.d.ts.map +1 -1
- package/dist/item-submission-materialization.js +2 -0
- package/dist/item-submission-materialization.js.map +1 -1
- package/dist/parser-processing.d.ts.map +1 -1
- package/dist/parser-processing.js +8 -18
- package/dist/parser-processing.js.map +1 -1
- package/dist/parser-values.d.ts.map +1 -1
- package/dist/parser-values.js +1 -3
- package/dist/parser-values.js.map +1 -1
- package/dist/parser.js +32 -0
- package/dist/parser.js.map +1 -1
- package/dist/processing-equality.d.ts +10 -0
- package/dist/processing-equality.d.ts.map +1 -0
- package/dist/processing-equality.js +78 -0
- package/dist/processing-equality.js.map +1 -0
- package/dist/processing-evaluator-comparison.d.ts.map +1 -1
- package/dist/processing-evaluator-comparison.js +14 -9
- package/dist/processing-evaluator-comparison.js.map +1 -1
- package/dist/processing-evaluator-numeric.d.ts.map +1 -1
- package/dist/processing-evaluator-numeric.js +2 -1
- package/dist/processing-evaluator-numeric.js.map +1 -1
- package/dist/processing-evaluator-string.d.ts.map +1 -1
- package/dist/processing-evaluator-string.js +26 -10
- package/dist/processing-evaluator-string.js.map +1 -1
- package/dist/processing-evaluator-variable.d.ts.map +1 -1
- package/dist/processing-evaluator-variable.js +9 -3
- package/dist/processing-evaluator-variable.js.map +1 -1
- package/dist/processing-evaluator.d.ts +5 -1
- package/dist/processing-evaluator.d.ts.map +1 -1
- package/dist/processing-evaluator.js +13 -4
- package/dist/processing-evaluator.js.map +1 -1
- package/dist/processing-expression-children.d.ts.map +1 -1
- package/dist/processing-expression-children.js +2 -3
- package/dist/processing-expression-children.js.map +1 -1
- package/dist/processing-expression-tags.d.ts +2 -1
- package/dist/processing-expression-tags.d.ts.map +1 -1
- package/dist/processing-expression-tags.js +2 -1
- package/dist/processing-expression-tags.js.map +1 -1
- package/dist/processing-mapping.d.ts.map +1 -1
- package/dist/processing-mapping.js +26 -16
- package/dist/processing-mapping.js.map +1 -1
- package/dist/processing-state.d.ts +2 -2
- package/dist/processing-state.d.ts.map +1 -1
- package/dist/processing-state.js +5 -2
- package/dist/processing-state.js.map +1 -1
- package/dist/processing-values.d.ts +1 -2
- package/dist/processing-values.d.ts.map +1 -1
- package/dist/processing-values.js +1 -3
- package/dist/processing-values.js.map +1 -1
- package/dist/processing-variables.d.ts.map +1 -1
- package/dist/processing-variables.js +10 -5
- package/dist/processing-variables.js.map +1 -1
- package/dist/qti-package-assets.d.ts.map +1 -1
- package/dist/qti-package-assets.js +16 -10
- package/dist/qti-package-assets.js.map +1 -1
- package/dist/qti-package-manifest.d.ts +1 -0
- package/dist/qti-package-manifest.d.ts.map +1 -1
- package/dist/qti-package-manifest.js +1 -0
- package/dist/qti-package-manifest.js.map +1 -1
- package/dist/qti-package-metadata.d.ts.map +1 -1
- package/dist/qti-package-metadata.js +11 -0
- package/dist/qti-package-metadata.js.map +1 -1
- package/dist/qti-package-plan.d.ts +1 -1
- package/dist/qti-package-plan.d.ts.map +1 -1
- package/dist/qti-package-types.d.ts +9 -0
- package/dist/qti-package-types.d.ts.map +1 -1
- package/dist/qti-package-xml.d.ts +6 -1
- package/dist/qti-package-xml.d.ts.map +1 -1
- package/dist/qti-package-xml.js +11 -6
- package/dist/qti-package-xml.js.map +1 -1
- package/dist/qti-package-zip.d.ts +13 -1
- package/dist/qti-package-zip.d.ts.map +1 -1
- package/dist/qti-package-zip.js +185 -54
- package/dist/qti-package-zip.js.map +1 -1
- package/dist/qti-package.d.ts +12 -5
- package/dist/qti-package.d.ts.map +1 -1
- package/dist/qti-package.js +63 -8
- package/dist/qti-package.js.map +1 -1
- package/dist/response-validation-policy.d.ts.map +1 -1
- package/dist/response-validation-policy.js +3 -1
- package/dist/response-validation-policy.js.map +1 -1
- package/dist/response-validation.d.ts.map +1 -1
- package/dist/response-validation.js +2 -1
- package/dist/response-validation.js.map +1 -1
- package/dist/serializer-processing-expressions.d.ts.map +1 -1
- package/dist/serializer-processing-expressions.js +2 -10
- package/dist/serializer-processing-expressions.js.map +1 -1
- package/dist/server-scoring.d.ts.map +1 -1
- package/dist/server-scoring.js +1 -0
- package/dist/server-scoring.js.map +1 -1
- package/dist/session-builtins.d.ts +16 -0
- package/dist/session-builtins.d.ts.map +1 -0
- package/dist/session-builtins.js +58 -0
- package/dist/session-builtins.js.map +1 -0
- package/dist/session.d.ts +5 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +63 -11
- package/dist/session.js.map +1 -1
- package/dist/support-evidence.d.ts.map +1 -1
- package/dist/support-evidence.js +2 -0
- package/dist/support-evidence.js.map +1 -1
- package/dist/support.d.ts.map +1 -1
- package/dist/support.js +64 -23
- package/dist/support.js.map +1 -1
- package/dist/trusted-item-session.d.ts +4 -1
- package/dist/trusted-item-session.d.ts.map +1 -1
- package/dist/trusted-item-session.js +8 -3
- package/dist/trusted-item-session.js.map +1 -1
- package/dist/types.d.ts +21 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validation-declaration-entries.d.ts.map +1 -1
- package/dist/validation-declaration-entries.js +11 -1
- package/dist/validation-declaration-entries.js.map +1 -1
- package/dist/validation-item-children.d.ts +4 -0
- package/dist/validation-item-children.d.ts.map +1 -0
- package/dist/validation-item-children.js +45 -0
- package/dist/validation-item-children.js.map +1 -0
- package/dist/validation-processing.d.ts.map +1 -1
- package/dist/validation-processing.js +45 -12
- package/dist/validation-processing.js.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +12 -0
- package/dist/validation.js.map +1 -1
- package/dist/value-guards.d.ts +4 -0
- package/dist/value-guards.d.ts.map +1 -0
- package/dist/value-guards.js +5 -0
- package/dist/value-guards.js.map +1 -0
- package/dist/xml-schema-regex-data.d.ts +48 -0
- package/dist/xml-schema-regex-data.d.ts.map +1 -0
- package/dist/xml-schema-regex-data.js +403 -0
- package/dist/xml-schema-regex-data.js.map +1 -0
- package/dist/xml-schema-regex-parser.d.ts +31 -0
- package/dist/xml-schema-regex-parser.d.ts.map +1 -0
- package/dist/xml-schema-regex-parser.js +245 -0
- package/dist/xml-schema-regex-parser.js.map +1 -0
- package/dist/xml-schema-regex.d.ts +14 -0
- package/dist/xml-schema-regex.d.ts.map +1 -0
- package/dist/xml-schema-regex.js +114 -0
- package/dist/xml-schema-regex.js.map +1 -0
- package/package.json +1 -1
- package/src/adaptive-turn-materializer.ts +4 -3
- package/src/adaptive-turn.ts +1 -0
- package/src/attempt-state.ts +54 -1
- package/src/diagnostics.ts +29 -0
- package/src/index.ts +9 -0
- package/src/item-submission-materialization.ts +2 -0
- package/src/parser-processing.ts +15 -18
- package/src/parser-values.ts +1 -5
- package/src/parser.ts +34 -0
- package/src/processing-equality.ts +104 -0
- package/src/processing-evaluator-comparison.ts +19 -14
- package/src/processing-evaluator-numeric.ts +2 -6
- package/src/processing-evaluator-string.ts +25 -18
- package/src/processing-evaluator-variable.ts +8 -3
- package/src/processing-evaluator.ts +20 -7
- package/src/processing-expression-children.ts +2 -3
- package/src/processing-expression-tags.ts +2 -1
- package/src/processing-mapping.ts +31 -25
- package/src/processing-state.ts +6 -1
- package/src/processing-values.ts +2 -11
- package/src/processing-variables.ts +9 -7
- package/src/qti-package-assets.ts +20 -13
- package/src/qti-package-manifest.ts +1 -0
- package/src/qti-package-metadata.ts +11 -0
- package/src/qti-package-plan.ts +1 -1
- package/src/qti-package-types.ts +10 -0
- package/src/qti-package-xml.ts +11 -6
- package/src/qti-package-zip.ts +330 -97
- package/src/qti-package.ts +106 -7
- package/src/response-validation-policy.ts +5 -1
- package/src/response-validation.ts +2 -1
- package/src/serializer-processing-expressions.ts +8 -25
- package/src/server-scoring.ts +1 -0
- package/src/session-builtins.ts +75 -0
- package/src/session.ts +64 -8
- package/src/support-evidence.ts +2 -0
- package/src/support.ts +84 -23
- package/src/trusted-item-session.ts +13 -2
- package/src/types.ts +30 -4
- package/src/validation-declaration-entries.ts +11 -1
- package/src/validation-item-children.ts +48 -0
- package/src/validation-processing.ts +47 -10
- package/src/validation.ts +14 -0
- package/src/value-guards.ts +6 -0
- package/src/xml-schema-regex-data.ts +404 -0
- package/src/xml-schema-regex-parser.ts +234 -0
- package/src/xml-schema-regex.ts +115 -0
package/src/qti-package-zip.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
207
|
+
function readZipDirectory(
|
|
98
208
|
bytes: Uint8Array,
|
|
99
|
-
|
|
209
|
+
limits: QtiPackageResourceLimits,
|
|
100
210
|
diagnostics: QtiDiagnostic[],
|
|
101
|
-
):
|
|
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:
|
|
262
|
+
const entries: ZipEntry[] = [];
|
|
128
263
|
|
|
129
264
|
for (let index = 0; index < entryCount; index += 1) {
|
|
130
|
-
if (offset + 46 >
|
|
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 >
|
|
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)
|
|
309
|
+
const decodedName = decodeUtf8(rawName);
|
|
173
310
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
174
|
-
if (
|
|
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
|
|
208
|
-
|
|
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 =
|
|
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
|
-
|
|
221
|
-
|
|
424
|
+
directoryStart,
|
|
425
|
+
ranges,
|
|
222
426
|
diagnostics,
|
|
223
427
|
);
|
|
224
|
-
if (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
|
|
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
|
-
|
|
241
|
-
|
|
487
|
+
directoryStart: number,
|
|
488
|
+
ranges: { start: number; end: number }[],
|
|
242
489
|
diagnostics: QtiDiagnostic[],
|
|
243
490
|
): Uint8Array | undefined {
|
|
244
|
-
if (entry.localHeaderOffset + 30 >
|
|
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 >
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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 (
|
|
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
|
+
}
|