codex-plugin-doctor 1.59.0 → 1.61.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 +40 -19
- package/dist/core/bounded-http-client.js +1 -0
- package/dist/core/mcp-discovery.d.ts +23 -0
- package/dist/core/mcp-discovery.js +148 -0
- package/dist/core/output-contract.js +62 -0
- package/dist/core/shell-completion.js +23 -0
- package/dist/core/submission-archive-preflight.d.ts +24 -0
- package/dist/core/submission-archive-preflight.js +259 -0
- package/dist/core/submission-archive-reader.d.ts +21 -0
- package/dist/core/submission-archive-reader.js +624 -0
- package/dist/core/submission-archive-ruleset.d.ts +58 -0
- package/dist/core/submission-archive-ruleset.js +61 -0
- package/dist/core/submission-assets.d.ts +3 -1
- package/dist/core/submission-assets.js +52 -31
- package/dist/core/submission-package-reader.d.ts +15 -0
- package/dist/core/submission-package-reader.js +274 -0
- package/dist/core/submission-preflight.d.ts +2 -0
- package/dist/core/submission-preflight.js +2 -2
- package/dist/core/submission-skill-metadata.d.ts +5 -3
- package/dist/core/submission-skill-metadata.js +117 -136
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reporting/render-mcp-discovery-report.d.ts +2 -0
- package/dist/reporting/render-mcp-discovery-report.js +14 -0
- package/dist/reporting/render-submission-archive-report.d.ts +4 -0
- package/dist/reporting/render-submission-archive-report.js +52 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +139 -1
- package/package.json +5 -2
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { close, fstat, open, read } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import iconv from "iconv-lite";
|
|
5
|
+
import * as yauzl from "yauzl";
|
|
6
|
+
const maxCompressedBytes = 100 * 1000 * 1000;
|
|
7
|
+
const maxEntries = 5_000;
|
|
8
|
+
const maxMemberBytes = 100 * 1024 * 1024;
|
|
9
|
+
const maxTotalBytes = 512 * 1024 * 1024;
|
|
10
|
+
const maxPathSegments = 20;
|
|
11
|
+
const invalidPackagePathMessage = "Invalid package path.";
|
|
12
|
+
const unsafeArchiveFileName = /[\u0000-\u001F\u007F\u2028\u2029\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
|
|
13
|
+
function sanitizedArchiveFileName(value) {
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
return "archive.zip";
|
|
16
|
+
const fileName = path.win32.basename(path.posix.basename(value));
|
|
17
|
+
return fileName === "" || fileName.trim() !== fileName || unsafeArchiveFileName.test(fileName)
|
|
18
|
+
? "archive.zip"
|
|
19
|
+
: fileName;
|
|
20
|
+
}
|
|
21
|
+
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
|
22
|
+
let value = index;
|
|
23
|
+
for (let bit = 0; bit < 8; bit += 1)
|
|
24
|
+
value = (value & 1) === 0 ? value >>> 1 : (value >>> 1) ^ 0xedb88320;
|
|
25
|
+
return value >>> 0;
|
|
26
|
+
});
|
|
27
|
+
function updateCrc32(value, content) {
|
|
28
|
+
let result = value;
|
|
29
|
+
for (const byte of content)
|
|
30
|
+
result = (result >>> 8) ^ crcTable[(result ^ byte) & 0xff];
|
|
31
|
+
return result >>> 0;
|
|
32
|
+
}
|
|
33
|
+
function archiveFinding(id, message, evidence, severity = "fail") {
|
|
34
|
+
return evidence === undefined ? { id, severity, message } : { id, severity, message, evidence };
|
|
35
|
+
}
|
|
36
|
+
function unavailableCoverage(reason) {
|
|
37
|
+
return { id: "plugin.submission.archive.compression_method", status: "unavailable", reason };
|
|
38
|
+
}
|
|
39
|
+
function blankInspection(fileName, compressedBytes = 0) {
|
|
40
|
+
return {
|
|
41
|
+
fileName,
|
|
42
|
+
compressedBytes,
|
|
43
|
+
uncompressedBytes: 0,
|
|
44
|
+
entryCount: 0,
|
|
45
|
+
entries: [],
|
|
46
|
+
findings: [],
|
|
47
|
+
coverage: [],
|
|
48
|
+
reader: null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function validSafeInteger(value) {
|
|
52
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
53
|
+
}
|
|
54
|
+
function openFileDescriptor(filePath) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
open(filePath, "r", (error, fileDescriptor) => {
|
|
57
|
+
if (error !== null)
|
|
58
|
+
reject(error);
|
|
59
|
+
else
|
|
60
|
+
resolve(fileDescriptor);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function statFileDescriptor(fileDescriptor) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
fstat(fileDescriptor, (error, details) => {
|
|
67
|
+
if (error !== null)
|
|
68
|
+
reject(error);
|
|
69
|
+
else
|
|
70
|
+
resolve(details);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function readFileDescriptor(fileDescriptor, content, offset, length, position) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
read(fileDescriptor, content, offset, length, position, (error, bytesRead) => {
|
|
77
|
+
if (error !== null)
|
|
78
|
+
reject(error);
|
|
79
|
+
else
|
|
80
|
+
resolve(bytesRead);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function closeFileDescriptor(fileDescriptor) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
close(fileDescriptor, (error) => {
|
|
87
|
+
if (error !== null)
|
|
88
|
+
reject(error);
|
|
89
|
+
else
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function archiveIdentity(details) {
|
|
95
|
+
return { dev: details.dev, ino: details.ino, size: details.size, mtimeMs: details.mtimeMs, ctimeMs: details.ctimeMs };
|
|
96
|
+
}
|
|
97
|
+
function matchesArchiveIdentity(details, expected) {
|
|
98
|
+
return details.isFile()
|
|
99
|
+
&& details.dev === expected.dev
|
|
100
|
+
&& details.ino === expected.ino
|
|
101
|
+
&& details.size === expected.size
|
|
102
|
+
&& details.mtimeMs === expected.mtimeMs
|
|
103
|
+
&& details.ctimeMs === expected.ctimeMs;
|
|
104
|
+
}
|
|
105
|
+
function normalizeRequestedPath(packagePath) {
|
|
106
|
+
if (typeof packagePath !== "string" || packagePath === "" || /[\u0000-\u001F\u007F]/u.test(packagePath)
|
|
107
|
+
|| packagePath.startsWith("/") || packagePath.startsWith("\\") || /^[a-zA-Z]:/u.test(packagePath)
|
|
108
|
+
|| packagePath.includes("\\"))
|
|
109
|
+
throw new Error(invalidPackagePathMessage);
|
|
110
|
+
const segments = packagePath.split("/");
|
|
111
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".."))
|
|
112
|
+
throw new Error(invalidPackagePathMessage);
|
|
113
|
+
return packagePath;
|
|
114
|
+
}
|
|
115
|
+
function decodePath(rawName, flags) {
|
|
116
|
+
try {
|
|
117
|
+
return (flags & 0x0800) !== 0
|
|
118
|
+
? new TextDecoder("utf-8", { fatal: true }).decode(rawName)
|
|
119
|
+
: iconv.decode(rawName, "cp437");
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function normalizeArchivePath(decodedPath) {
|
|
126
|
+
if (decodedPath.length === 0 || decodedPath.trim() !== decodedPath || /[\u0000-\u001F\u007F]/u.test(decodedPath)
|
|
127
|
+
|| decodedPath.startsWith("/") || decodedPath.startsWith("\\") || /^[a-zA-Z]:/u.test(decodedPath)
|
|
128
|
+
|| decodedPath.includes("\\"))
|
|
129
|
+
return null;
|
|
130
|
+
const directory = decodedPath.endsWith("/");
|
|
131
|
+
const withoutTrailingSlash = directory ? decodedPath.slice(0, -1) : decodedPath;
|
|
132
|
+
const segments = withoutTrailingSlash.split("/");
|
|
133
|
+
if (withoutTrailingSlash.length === 0 || segments.length > maxPathSegments
|
|
134
|
+
|| segments.some((segment) => segment === "" || segment === "." || segment === ".."))
|
|
135
|
+
return null;
|
|
136
|
+
return { path: segments.join("/"), directory };
|
|
137
|
+
}
|
|
138
|
+
function entryKind(entry, trailingDirectory) {
|
|
139
|
+
const madeByUnix = entry.versionMadeBy >>> 8 === 3;
|
|
140
|
+
const unixType = madeByUnix ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0;
|
|
141
|
+
const dosDirectory = (entry.externalFileAttributes & 0x10) !== 0;
|
|
142
|
+
if (unixType === 0o120000)
|
|
143
|
+
return "symlink";
|
|
144
|
+
if (unixType !== 0 && unixType !== 0o100000 && unixType !== 0o040000)
|
|
145
|
+
return "other";
|
|
146
|
+
if (trailingDirectory || dosDirectory || unixType === 0o040000)
|
|
147
|
+
return "directory";
|
|
148
|
+
return "file";
|
|
149
|
+
}
|
|
150
|
+
function hasContradictoryType(entry, trailingDirectory, kind) {
|
|
151
|
+
const madeByUnix = entry.versionMadeBy >>> 8 === 3;
|
|
152
|
+
const unixType = madeByUnix ? (entry.externalFileAttributes >>> 16) & 0o170000 : 0;
|
|
153
|
+
const dosDirectory = (entry.externalFileAttributes & 0x10) !== 0;
|
|
154
|
+
return ((trailingDirectory || dosDirectory) && unixType === 0o100000)
|
|
155
|
+
|| (unixType === 0o040000 && kind !== "directory")
|
|
156
|
+
|| ((trailingDirectory || dosDirectory) && kind !== "directory");
|
|
157
|
+
}
|
|
158
|
+
function unicodePathChangesDecodedName(entry, rawName, decodedPath) {
|
|
159
|
+
const field = entry.extraFields.find((candidate) => candidate.id === 0x7075);
|
|
160
|
+
if (field === undefined || field.data.length < 5 || field.data[0] !== 1)
|
|
161
|
+
return false;
|
|
162
|
+
const expectedCrc = field.data.readUInt32LE(1);
|
|
163
|
+
if (expectedCrc !== crc32(rawName))
|
|
164
|
+
return false;
|
|
165
|
+
try {
|
|
166
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(field.data.subarray(5)) !== decodedPath;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function crc32(content) {
|
|
173
|
+
return (updateCrc32(0xffffffff, content) ^ 0xffffffff) >>> 0;
|
|
174
|
+
}
|
|
175
|
+
async function readExactly(fileDescriptor, position, length) {
|
|
176
|
+
const content = Buffer.alloc(length);
|
|
177
|
+
let offset = 0;
|
|
178
|
+
while (offset < length) {
|
|
179
|
+
const bytesRead = await readFileDescriptor(fileDescriptor, content, offset, length - offset, position + offset);
|
|
180
|
+
if (bytesRead === 0)
|
|
181
|
+
return null;
|
|
182
|
+
offset += bytesRead;
|
|
183
|
+
}
|
|
184
|
+
return content;
|
|
185
|
+
}
|
|
186
|
+
function readSafeUInt64(content, offset) {
|
|
187
|
+
if (offset + 8 > content.length)
|
|
188
|
+
return null;
|
|
189
|
+
const value = content.readBigUInt64LE(offset);
|
|
190
|
+
return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : null;
|
|
191
|
+
}
|
|
192
|
+
function parseLocalExtraFields(content) {
|
|
193
|
+
const fields = [];
|
|
194
|
+
let offset = 0;
|
|
195
|
+
while (offset < content.length) {
|
|
196
|
+
if (content.length - offset < 4)
|
|
197
|
+
return null;
|
|
198
|
+
const id = content.readUInt16LE(offset);
|
|
199
|
+
const length = content.readUInt16LE(offset + 2);
|
|
200
|
+
offset += 4;
|
|
201
|
+
if (length > content.length - offset)
|
|
202
|
+
return null;
|
|
203
|
+
fields.push({ id, data: content.subarray(offset, offset + length) });
|
|
204
|
+
offset += length;
|
|
205
|
+
}
|
|
206
|
+
return fields;
|
|
207
|
+
}
|
|
208
|
+
async function readCentralEntryLayout(fileDescriptor, offset, fileSize) {
|
|
209
|
+
if (!validSafeInteger(offset) || offset + 46 > fileSize)
|
|
210
|
+
return null;
|
|
211
|
+
const fixed = await readExactly(fileDescriptor, offset, 46);
|
|
212
|
+
if (fixed === null || fixed.readUInt32LE(0) !== 0x02014b50)
|
|
213
|
+
return null;
|
|
214
|
+
const compressedSize = fixed.readUInt32LE(20);
|
|
215
|
+
const uncompressedSize = fixed.readUInt32LE(24);
|
|
216
|
+
const nameLength = fixed.readUInt16LE(28);
|
|
217
|
+
const extraLength = fixed.readUInt16LE(30);
|
|
218
|
+
const commentLength = fixed.readUInt16LE(32);
|
|
219
|
+
const nextOffset = offset + 46 + nameLength + extraLength + commentLength;
|
|
220
|
+
if (!validSafeInteger(nextOffset) || nextOffset > fileSize)
|
|
221
|
+
return null;
|
|
222
|
+
const expectedZip64SizeBytes = (uncompressedSize === 0xffffffff ? 8 : 0) + (compressedSize === 0xffffffff ? 8 : 0);
|
|
223
|
+
if (expectedZip64SizeBytes === 0)
|
|
224
|
+
return { nextOffset, usesZip64Sizes: false };
|
|
225
|
+
const extra = await readExactly(fileDescriptor, offset + 46 + nameLength, extraLength);
|
|
226
|
+
const extraFields = extra === null ? null : parseLocalExtraFields(extra);
|
|
227
|
+
const zip64Fields = extraFields?.filter((field) => field.id === 0x0001) ?? [];
|
|
228
|
+
if (zip64Fields.length !== 1 || zip64Fields[0].data.length < expectedZip64SizeBytes)
|
|
229
|
+
return null;
|
|
230
|
+
return { nextOffset, usesZip64Sizes: true };
|
|
231
|
+
}
|
|
232
|
+
async function readLocalHeader(fileDescriptor, entry, fileSize, usesZip64Sizes) {
|
|
233
|
+
const offset = entry.relativeOffsetOfLocalHeader;
|
|
234
|
+
if (!validSafeInteger(offset) || offset + 30 > fileSize)
|
|
235
|
+
return null;
|
|
236
|
+
const fixed = await readExactly(fileDescriptor, offset, 30);
|
|
237
|
+
if (fixed === null || fixed.readUInt32LE(0) !== 0x04034b50)
|
|
238
|
+
return null;
|
|
239
|
+
const flags = fixed.readUInt16LE(6);
|
|
240
|
+
const method = fixed.readUInt16LE(8);
|
|
241
|
+
const crc = fixed.readUInt32LE(14);
|
|
242
|
+
const compressedSize = fixed.readUInt32LE(18);
|
|
243
|
+
const uncompressedSize = fixed.readUInt32LE(22);
|
|
244
|
+
const nameLength = fixed.readUInt16LE(26);
|
|
245
|
+
const extraLength = fixed.readUInt16LE(28);
|
|
246
|
+
const dataStart = offset + 30 + nameLength + extraLength;
|
|
247
|
+
if (!validSafeInteger(dataStart) || dataStart > fileSize)
|
|
248
|
+
return null;
|
|
249
|
+
const rawName = await readExactly(fileDescriptor, offset + 30, nameLength);
|
|
250
|
+
const extra = await readExactly(fileDescriptor, offset + 30 + nameLength, extraLength);
|
|
251
|
+
const extraFields = extra === null ? null : parseLocalExtraFields(extra);
|
|
252
|
+
if (rawName === null || extraFields === null || !validSafeInteger(entry.compressedSize) || dataStart + entry.compressedSize > fileSize)
|
|
253
|
+
return null;
|
|
254
|
+
let resolvedCompressedSize = compressedSize;
|
|
255
|
+
let resolvedUncompressedSize = uncompressedSize;
|
|
256
|
+
if ((flags & 0x0008) === 0 && (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff)) {
|
|
257
|
+
const zip64Fields = extraFields.filter((field) => field.id === 0x0001);
|
|
258
|
+
if (zip64Fields.length !== 1)
|
|
259
|
+
return null;
|
|
260
|
+
let zip64Offset = 0;
|
|
261
|
+
if (uncompressedSize === 0xffffffff) {
|
|
262
|
+
const value = readSafeUInt64(zip64Fields[0].data, zip64Offset);
|
|
263
|
+
if (value === null)
|
|
264
|
+
return null;
|
|
265
|
+
resolvedUncompressedSize = value;
|
|
266
|
+
zip64Offset += 8;
|
|
267
|
+
}
|
|
268
|
+
if (compressedSize === 0xffffffff) {
|
|
269
|
+
const value = readSafeUInt64(zip64Fields[0].data, zip64Offset);
|
|
270
|
+
if (value === null)
|
|
271
|
+
return null;
|
|
272
|
+
resolvedCompressedSize = value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let descriptorLength = 0;
|
|
276
|
+
if ((flags & 0x0008) !== 0) {
|
|
277
|
+
const zip64Descriptor = usesZip64Sizes;
|
|
278
|
+
const descriptor = await readExactly(fileDescriptor, dataStart + entry.compressedSize, zip64Descriptor ? 24 : 16);
|
|
279
|
+
if (descriptor === null)
|
|
280
|
+
return null;
|
|
281
|
+
const signed = descriptor.readUInt32LE(0) === 0x08074b50;
|
|
282
|
+
const base = signed ? 4 : 0;
|
|
283
|
+
const descriptorLengthWithoutSignature = zip64Descriptor ? 20 : 12;
|
|
284
|
+
if (descriptor.length < base + descriptorLengthWithoutSignature
|
|
285
|
+
|| descriptor.readUInt32LE(base) !== entry.crc32)
|
|
286
|
+
return null;
|
|
287
|
+
const compressedSize = zip64Descriptor
|
|
288
|
+
? readSafeUInt64(descriptor, base + 4)
|
|
289
|
+
: descriptor.readUInt32LE(base + 4);
|
|
290
|
+
const uncompressedSize = zip64Descriptor
|
|
291
|
+
? readSafeUInt64(descriptor, base + 12)
|
|
292
|
+
: descriptor.readUInt32LE(base + 8);
|
|
293
|
+
if (compressedSize === null || uncompressedSize === null
|
|
294
|
+
|| compressedSize !== entry.compressedSize || uncompressedSize !== entry.uncompressedSize)
|
|
295
|
+
return null;
|
|
296
|
+
descriptorLength = (signed ? 4 : 0) + descriptorLengthWithoutSignature;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
flags,
|
|
300
|
+
method,
|
|
301
|
+
crc32: crc,
|
|
302
|
+
compressedSize: resolvedCompressedSize,
|
|
303
|
+
uncompressedSize: resolvedUncompressedSize,
|
|
304
|
+
rawName,
|
|
305
|
+
dataStart,
|
|
306
|
+
intervalEnd: dataStart + entry.compressedSize + descriptorLength
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
async function readArchiveMetadata(fileDescriptor, fileSize) {
|
|
310
|
+
const tailLength = Math.min(fileSize, 0xffff + 22 + 20);
|
|
311
|
+
const tail = await readExactly(fileDescriptor, fileSize - tailLength, tailLength);
|
|
312
|
+
if (tail === null)
|
|
313
|
+
return null;
|
|
314
|
+
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
|
315
|
+
if (tail.readUInt32LE(offset) !== 0x06054b50)
|
|
316
|
+
continue;
|
|
317
|
+
const commentLength = tail.readUInt16LE(offset + 20);
|
|
318
|
+
if (offset + 22 + commentLength !== tail.length)
|
|
319
|
+
continue;
|
|
320
|
+
const eocdOffset = fileSize - tailLength + offset;
|
|
321
|
+
const disk = tail.readUInt16LE(offset + 4);
|
|
322
|
+
const centralDisk = tail.readUInt16LE(offset + 6);
|
|
323
|
+
const entriesOnDisk = tail.readUInt16LE(offset + 8);
|
|
324
|
+
const entryCount = tail.readUInt16LE(offset + 10);
|
|
325
|
+
const centralSize32 = tail.readUInt32LE(offset + 12);
|
|
326
|
+
const centralStart32 = tail.readUInt32LE(offset + 16);
|
|
327
|
+
const zip64 = entriesOnDisk === 0xffff || entryCount === 0xffff || centralSize32 === 0xffffffff || centralStart32 === 0xffffffff;
|
|
328
|
+
if (!zip64) {
|
|
329
|
+
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount
|
|
330
|
+
|| centralStart32 > eocdOffset
|
|
331
|
+
|| centralSize32 > eocdOffset - centralStart32)
|
|
332
|
+
return null;
|
|
333
|
+
if (entryCount === 0 && centralSize32 === 0 && centralStart32 === eocdOffset) {
|
|
334
|
+
return { centralStart: centralStart32, metadataStart: centralStart32 };
|
|
335
|
+
}
|
|
336
|
+
const signature = await readExactly(fileDescriptor, centralStart32, 4);
|
|
337
|
+
return signature?.readUInt32LE(0) === 0x02014b50
|
|
338
|
+
? { centralStart: centralStart32, metadataStart: centralStart32 }
|
|
339
|
+
: null;
|
|
340
|
+
}
|
|
341
|
+
if (eocdOffset < 20)
|
|
342
|
+
return null;
|
|
343
|
+
const locator = await readExactly(fileDescriptor, eocdOffset - 20, 20);
|
|
344
|
+
if (locator === null || locator.readUInt32LE(0) !== 0x07064b50 || locator.readUInt32LE(4) !== 0 || locator.readUInt32LE(16) !== 1)
|
|
345
|
+
return null;
|
|
346
|
+
const zip64Offset = readSafeUInt64(locator, 8);
|
|
347
|
+
if (zip64Offset === null || zip64Offset > eocdOffset - 20 - 56)
|
|
348
|
+
return null;
|
|
349
|
+
const zip64Record = await readExactly(fileDescriptor, zip64Offset, 56);
|
|
350
|
+
const zip64RecordSize = zip64Record === null ? null : readSafeUInt64(zip64Record, 4);
|
|
351
|
+
if (zip64Record === null || zip64Record.readUInt32LE(0) !== 0x06064b50 || zip64RecordSize === null
|
|
352
|
+
|| zip64RecordSize < 44 || zip64RecordSize > eocdOffset - 20 - zip64Offset - 12
|
|
353
|
+
|| zip64Record.readUInt32LE(16) !== 0 || zip64Record.readUInt32LE(20) !== 0)
|
|
354
|
+
return null;
|
|
355
|
+
const zip64EntriesOnDisk = readSafeUInt64(zip64Record, 24);
|
|
356
|
+
const zip64Entries = readSafeUInt64(zip64Record, 32);
|
|
357
|
+
const centralSize = readSafeUInt64(zip64Record, 40);
|
|
358
|
+
const centralStart = readSafeUInt64(zip64Record, 48);
|
|
359
|
+
if (zip64EntriesOnDisk === null || zip64Entries === null || centralSize === null || centralStart === null
|
|
360
|
+
|| zip64EntriesOnDisk !== zip64Entries || centralStart > zip64Offset
|
|
361
|
+
|| centralSize > zip64Offset - centralStart)
|
|
362
|
+
return null;
|
|
363
|
+
const signature = await readExactly(fileDescriptor, centralStart, 4);
|
|
364
|
+
return signature?.readUInt32LE(0) === 0x02014b50
|
|
365
|
+
? { centralStart, metadataStart: Math.min(centralStart, zip64Offset) }
|
|
366
|
+
: null;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
async function streamAndValidate(zip, entry, expectedCrc, expectedSize, currentTotal) {
|
|
371
|
+
try {
|
|
372
|
+
const stream = await zip.openReadStreamPromise(entry);
|
|
373
|
+
let actualBytes = 0;
|
|
374
|
+
let crc = 0xffffffff;
|
|
375
|
+
for await (const chunk of stream) {
|
|
376
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
377
|
+
if (actualBytes + bytes.length > expectedSize || currentTotal + actualBytes + bytes.length > maxTotalBytes) {
|
|
378
|
+
stream.destroy();
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
actualBytes += bytes.length;
|
|
382
|
+
crc = updateCrc32(crc, bytes);
|
|
383
|
+
}
|
|
384
|
+
return actualBytes === expectedSize && ((crc ^ 0xffffffff) >>> 0) === expectedCrc
|
|
385
|
+
? { actualBytes, totalBytes: currentTotal + actualBytes }
|
|
386
|
+
: null;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
async function openZip(fileDescriptor) {
|
|
393
|
+
return yauzl.fromFdPromise(fileDescriptor, {
|
|
394
|
+
autoClose: false,
|
|
395
|
+
lazyEntries: true,
|
|
396
|
+
decodeStrings: false,
|
|
397
|
+
strictFileNames: false,
|
|
398
|
+
validateEntrySizes: false
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function createArchiveReader(zipPath, checkedEntries, expectedIdentity) {
|
|
402
|
+
const byPath = new Map(checkedEntries.map((entry) => [entry.packageEntry.packagePath, entry]));
|
|
403
|
+
const packageEntries = checkedEntries.map((entry) => entry.packageEntry);
|
|
404
|
+
return {
|
|
405
|
+
async list(directory) {
|
|
406
|
+
const normalizedDirectory = directory === "" ? "" : normalizeRequestedPath(directory.endsWith("/") ? directory.slice(0, -1) : directory);
|
|
407
|
+
const prefix = normalizedDirectory === "" ? "" : `${normalizedDirectory}/`;
|
|
408
|
+
return packageEntries.filter((entry) => entry.packagePath.startsWith(prefix) && !entry.packagePath.slice(prefix.length).includes("/"));
|
|
409
|
+
},
|
|
410
|
+
async stat(packagePath) {
|
|
411
|
+
return byPath.get(normalizeRequestedPath(packagePath))?.packageEntry ?? null;
|
|
412
|
+
},
|
|
413
|
+
async read(packagePath, maxBytes) {
|
|
414
|
+
const selected = byPath.get(normalizeRequestedPath(packagePath));
|
|
415
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
|
416
|
+
throw new Error("maxBytes must be a nonnegative safe integer.");
|
|
417
|
+
if (selected === undefined || selected.packageEntry.resolvedKind !== "file" || selected.uncompressedSize > maxBytes)
|
|
418
|
+
return null;
|
|
419
|
+
let zip = null;
|
|
420
|
+
let fileDescriptor = null;
|
|
421
|
+
try {
|
|
422
|
+
fileDescriptor = await openFileDescriptor(zipPath);
|
|
423
|
+
if (!matchesArchiveIdentity(await statFileDescriptor(fileDescriptor), expectedIdentity))
|
|
424
|
+
return null;
|
|
425
|
+
zip = await openZip(fileDescriptor);
|
|
426
|
+
fileDescriptor = null;
|
|
427
|
+
let index = 0;
|
|
428
|
+
for await (const entry of zip.eachEntry()) {
|
|
429
|
+
if (index++ !== selected.index)
|
|
430
|
+
continue;
|
|
431
|
+
const stream = await zip.openReadStreamPromise(entry);
|
|
432
|
+
const result = Buffer.alloc(selected.uncompressedSize);
|
|
433
|
+
let offset = 0;
|
|
434
|
+
let crc = 0xffffffff;
|
|
435
|
+
for await (const chunk of stream) {
|
|
436
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
437
|
+
if (offset + bytes.length > result.length)
|
|
438
|
+
return null;
|
|
439
|
+
bytes.copy(result, offset);
|
|
440
|
+
offset += bytes.length;
|
|
441
|
+
crc = updateCrc32(crc, bytes);
|
|
442
|
+
}
|
|
443
|
+
return offset === result.length && ((crc ^ 0xffffffff) >>> 0) === selected.crc32
|
|
444
|
+
? Uint8Array.from(result)
|
|
445
|
+
: null;
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
zip?.close();
|
|
454
|
+
if (fileDescriptor !== null)
|
|
455
|
+
await closeFileDescriptor(fileDescriptor).catch(() => undefined);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
export async function inspectSubmissionArchive(zipPath) {
|
|
461
|
+
const fileName = sanitizedArchiveFileName(zipPath);
|
|
462
|
+
let details;
|
|
463
|
+
try {
|
|
464
|
+
if (typeof zipPath !== "string" || path.extname(zipPath).toLowerCase() !== ".zip") {
|
|
465
|
+
const report = blankInspection(fileName);
|
|
466
|
+
report.findings.push(archiveFinding("plugin.submission.archive.invalid_file", "Input must be a regular .zip file."));
|
|
467
|
+
return report;
|
|
468
|
+
}
|
|
469
|
+
details = await stat(zipPath);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
const report = blankInspection(fileName);
|
|
473
|
+
report.findings.push(archiveFinding("plugin.submission.archive.invalid_file", "Input must be a readable regular .zip file."));
|
|
474
|
+
return report;
|
|
475
|
+
}
|
|
476
|
+
const report = blankInspection(fileName, validSafeInteger(details.size) ? details.size : 0);
|
|
477
|
+
if (!details.isFile() || !validSafeInteger(details.size) || details.size === 0) {
|
|
478
|
+
report.findings.push(archiveFinding("plugin.submission.archive.invalid_file", "Input must be a non-empty regular .zip file."));
|
|
479
|
+
return report;
|
|
480
|
+
}
|
|
481
|
+
if (details.size > maxCompressedBytes) {
|
|
482
|
+
report.findings.push(archiveFinding("plugin.submission.archive.too_large", "Archive exceeds the compressed size limit.", { limit: maxCompressedBytes }));
|
|
483
|
+
return report;
|
|
484
|
+
}
|
|
485
|
+
let zip = null;
|
|
486
|
+
let fileDescriptor = null;
|
|
487
|
+
try {
|
|
488
|
+
fileDescriptor = await openFileDescriptor(zipPath);
|
|
489
|
+
const openedDetails = await statFileDescriptor(fileDescriptor);
|
|
490
|
+
if (!matchesArchiveIdentity(openedDetails, archiveIdentity(details))) {
|
|
491
|
+
report.findings.push(archiveFinding("plugin.submission.archive.invalid_zip", "Archive changed while opening for inspection."));
|
|
492
|
+
return report;
|
|
493
|
+
}
|
|
494
|
+
const metadata = await readArchiveMetadata(fileDescriptor, openedDetails.size);
|
|
495
|
+
if (metadata === null) {
|
|
496
|
+
report.findings.push(archiveFinding("plugin.submission.archive.range_invalid", "Archive metadata ranges are invalid."));
|
|
497
|
+
return report;
|
|
498
|
+
}
|
|
499
|
+
const inspectionDescriptor = fileDescriptor;
|
|
500
|
+
zip = await openZip(inspectionDescriptor);
|
|
501
|
+
fileDescriptor = null;
|
|
502
|
+
if (!validSafeInteger(zip.entryCount) || zip.entryCount > maxEntries) {
|
|
503
|
+
report.findings.push(archiveFinding("plugin.submission.archive.entry_count", "Archive entry count exceeds the limit.", { limit: maxEntries }));
|
|
504
|
+
return report;
|
|
505
|
+
}
|
|
506
|
+
report.entryCount = zip.entryCount;
|
|
507
|
+
const checked = [];
|
|
508
|
+
const intervals = [];
|
|
509
|
+
const paths = new Map();
|
|
510
|
+
const normalizedPaths = new Set();
|
|
511
|
+
let declaredTotal = 0;
|
|
512
|
+
let actualTotal = 0;
|
|
513
|
+
let unsupportedCompression = false;
|
|
514
|
+
let index = 0;
|
|
515
|
+
let centralEntryOffset = metadata.centralStart;
|
|
516
|
+
for await (const entry of zip.eachEntry()) {
|
|
517
|
+
const entryIndex = index++;
|
|
518
|
+
const central = await readCentralEntryLayout(inspectionDescriptor, centralEntryOffset, details.size);
|
|
519
|
+
if (central === null) {
|
|
520
|
+
report.findings.push(archiveFinding("plugin.submission.archive.range_invalid", "Archive central directory is invalid.", { entryIndex }));
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
centralEntryOffset = central.nextOffset;
|
|
524
|
+
const rawName = entry.fileName;
|
|
525
|
+
const decodedPath = decodePath(rawName, entry.generalPurposeBitFlag);
|
|
526
|
+
const normalized = decodedPath === null ? null : normalizeArchivePath(decodedPath);
|
|
527
|
+
if (normalized === null || decodedPath === null) {
|
|
528
|
+
report.findings.push(archiveFinding("plugin.submission.archive.path_invalid", "Archive entry path is invalid.", { entryIndex }));
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (!validSafeInteger(entry.compressedSize) || !validSafeInteger(entry.uncompressedSize)
|
|
532
|
+
|| !validSafeInteger(entry.crc32) || entry.uncompressedSize > maxMemberBytes) {
|
|
533
|
+
report.findings.push(archiveFinding("plugin.submission.archive.member_too_large", "Archive entry exceeds a supported size limit.", { entryIndex, limit: maxMemberBytes }));
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
declaredTotal += entry.uncompressedSize;
|
|
537
|
+
if (!validSafeInteger(declaredTotal) || declaredTotal > maxTotalBytes) {
|
|
538
|
+
report.findings.push(archiveFinding("plugin.submission.archive.total_too_large", "Archive exceeds the total uncompressed size limit.", { limit: maxTotalBytes }));
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const local = await readLocalHeader(inspectionDescriptor, entry, details.size, central.usesZip64Sizes);
|
|
542
|
+
if (local === null) {
|
|
543
|
+
report.findings.push(archiveFinding((entry.generalPurposeBitFlag & 0x0008) !== 0 ? "plugin.submission.archive.descriptor_invalid" : "plugin.submission.archive.range_invalid", "Archive local header or descriptor is invalid.", { entryIndex }));
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (!rawName.equals(local.rawName) || local.flags !== entry.generalPurposeBitFlag || local.method !== entry.compressionMethod
|
|
547
|
+
|| ((entry.generalPurposeBitFlag & 0x0008) === 0 && (local.crc32 !== entry.crc32 || local.compressedSize !== entry.compressedSize || local.uncompressedSize !== entry.uncompressedSize))) {
|
|
548
|
+
report.findings.push(archiveFinding("plugin.submission.archive.header_mismatch", "Archive central and local headers disagree.", { entryIndex }));
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
intervals.push({ start: entry.relativeOffsetOfLocalHeader, end: local.intervalEnd });
|
|
552
|
+
const kind = entryKind(entry, normalized.directory);
|
|
553
|
+
if (hasContradictoryType(entry, normalized.directory, kind) || kind === "symlink" || kind === "other") {
|
|
554
|
+
report.findings.push(archiveFinding("plugin.submission.archive.type_unsupported", "Archive entry type is unsupported.", { entryIndex }));
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const packageEntry = {
|
|
558
|
+
packagePath: normalized.path,
|
|
559
|
+
kind,
|
|
560
|
+
resolvedKind: kind,
|
|
561
|
+
size: entry.uncompressedSize,
|
|
562
|
+
safeResolution: "safe"
|
|
563
|
+
};
|
|
564
|
+
if (paths.has(normalized.path)) {
|
|
565
|
+
report.findings.push(archiveFinding("plugin.submission.archive.path_duplicate", "Archive contains duplicate entry paths.", { path: normalized.path }));
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if ([...paths.keys()].some((existing) => existing.startsWith(`${normalized.path}/`) || normalized.path.startsWith(`${existing}/`))) {
|
|
569
|
+
report.findings.push(archiveFinding("plugin.submission.archive.path_conflict", "Archive entry paths conflict.", { path: normalized.path }));
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const collisionKey = normalized.path.split("/").map((segment) => segment.normalize("NFKC").toLowerCase()).join("/");
|
|
573
|
+
if (normalizedPaths.has(collisionKey)) {
|
|
574
|
+
report.findings.push(archiveFinding("plugin.submission.archive.normalization_collision", "Archive paths collide under Doctor's local normalization check.", { path: normalized.path }, "warn"));
|
|
575
|
+
}
|
|
576
|
+
normalizedPaths.add(collisionKey);
|
|
577
|
+
paths.set(normalized.path, packageEntry);
|
|
578
|
+
if (unicodePathChangesDecodedName(entry, rawName, decodedPath)) {
|
|
579
|
+
report.coverage.push({ id: "plugin.submission.archive.filename_decoding", status: "unavailable", reason: "Unicode Path extra fields may change portal filename decoding." });
|
|
580
|
+
}
|
|
581
|
+
if ((entry.generalPurposeBitFlag & 0x0001) !== 0) {
|
|
582
|
+
report.findings.push(archiveFinding("plugin.submission.archive.encrypted", "Encrypted archive entries cannot be inspected.", { path: normalized.path }));
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (kind !== "file") {
|
|
586
|
+
checked.push({ index: entryIndex, packageEntry, crc32: entry.crc32, compressionMethod: entry.compressionMethod, uncompressedSize: entry.uncompressedSize });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
|
590
|
+
unsupportedCompression = true;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const streamed = await streamAndValidate(zip, entry, entry.crc32, entry.uncompressedSize, actualTotal);
|
|
594
|
+
if (streamed === null) {
|
|
595
|
+
report.findings.push(archiveFinding("plugin.submission.archive.crc_mismatch", "Archive entry content does not match declared CRC or size.", { path: normalized.path }));
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
actualTotal = streamed.totalBytes;
|
|
599
|
+
checked.push({ index: entryIndex, packageEntry, crc32: entry.crc32, compressionMethod: entry.compressionMethod, uncompressedSize: entry.uncompressedSize });
|
|
600
|
+
}
|
|
601
|
+
intervals.sort((left, right) => left.start - right.start);
|
|
602
|
+
if (intervals.some((interval, intervalIndex) => interval.end > metadata.metadataStart
|
|
603
|
+
|| (intervalIndex > 0 && interval.start < intervals[intervalIndex - 1].end))) {
|
|
604
|
+
report.findings.push(archiveFinding("plugin.submission.archive.range_invalid", "Archive entry data ranges overlap or exceed the archive."));
|
|
605
|
+
}
|
|
606
|
+
report.uncompressedBytes = actualTotal;
|
|
607
|
+
report.entries = checked.map((entry) => entry.packageEntry).sort((left, right) => left.packagePath.localeCompare(right.packagePath));
|
|
608
|
+
if (unsupportedCompression)
|
|
609
|
+
report.coverage.push(unavailableCoverage("Doctor does not decode this well-formed compression method."));
|
|
610
|
+
if (report.findings.some((finding) => finding.severity === "fail") || unsupportedCompression)
|
|
611
|
+
return report;
|
|
612
|
+
report.reader = createArchiveReader(zipPath, checked, archiveIdentity(openedDetails));
|
|
613
|
+
return report;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
report.findings.push(archiveFinding("plugin.submission.archive.invalid_zip", "Archive is malformed or truncated."));
|
|
617
|
+
return report;
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
zip?.close();
|
|
621
|
+
if (fileDescriptor !== null)
|
|
622
|
+
await closeFileDescriptor(fileDescriptor).catch(() => undefined);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const submissionArchiveRuleset: Readonly<{
|
|
2
|
+
version: "openai-directory-archive-2026-08-23";
|
|
3
|
+
reviewedAt: "2026-08-23";
|
|
4
|
+
sources: readonly ["https://developers.openai.com/plugins/build/plugins", "https://developers.openai.com/plugins/deploy/submission-errors"];
|
|
5
|
+
limits: Readonly<{
|
|
6
|
+
compressedBytes: number;
|
|
7
|
+
entries: 5000;
|
|
8
|
+
memberBytes: number;
|
|
9
|
+
totalBytes: number;
|
|
10
|
+
}>;
|
|
11
|
+
structures: Readonly<{
|
|
12
|
+
eocdComments: "automatic";
|
|
13
|
+
zip64: "automatic";
|
|
14
|
+
dataDescriptors: "automatic";
|
|
15
|
+
}>;
|
|
16
|
+
compression: Readonly<{
|
|
17
|
+
stored: "automatic";
|
|
18
|
+
deflate: "automatic";
|
|
19
|
+
}>;
|
|
20
|
+
collisionAlgorithm: "NFKC + toLowerCase per segment";
|
|
21
|
+
automaticChecks: readonly ["plugin.submission.archive.invalid_file", "plugin.submission.archive.invalid_zip", "plugin.submission.archive.too_large", "plugin.submission.archive.entry_count", "plugin.submission.archive.member_too_large", "plugin.submission.archive.total_too_large", "plugin.submission.archive.encrypted", "plugin.submission.archive.header_mismatch", "plugin.submission.archive.descriptor_invalid", "plugin.submission.archive.range_invalid", "plugin.submission.archive.crc_mismatch", "plugin.submission.archive.path_invalid", "plugin.submission.archive.path_duplicate", "plugin.submission.archive.path_conflict", "plugin.submission.archive.type_unsupported", "plugin.submission.archive.normalization_collision", "plugin.submission.archive.root_missing", "plugin.submission.archive.root_ambiguous", "plugin.submission.archive.root_siblings", "plugin.submission.archive.manifest_missing", "plugin.submission.archive.skill_missing", "plugin.submission.archive.mcp_excluded", "plugin.submission.archive.app_excluded", "plugin.submission.archive.screenshot_excluded"];
|
|
22
|
+
portalRules: readonly [Readonly<{
|
|
23
|
+
id: "plugin.submission.archive.plugin_name_mismatch";
|
|
24
|
+
status: "unavailable";
|
|
25
|
+
reason: "Requires the previously published plugin identity.";
|
|
26
|
+
source: "submission-errors";
|
|
27
|
+
}>, Readonly<{
|
|
28
|
+
id: "plugin.submission.archive.plugin_version_unchanged";
|
|
29
|
+
status: "unavailable";
|
|
30
|
+
reason: "Requires the previously published plugin version.";
|
|
31
|
+
source: "submission-errors";
|
|
32
|
+
}>, Readonly<{
|
|
33
|
+
id: "plugin.submission.archive.manifest_normalized";
|
|
34
|
+
status: "unavailable";
|
|
35
|
+
reason: "The portal owns exact manifest normalization.";
|
|
36
|
+
source: "submission-errors";
|
|
37
|
+
}>, Readonly<{
|
|
38
|
+
id: "plugin.submission.archive.developer_name_defaulted";
|
|
39
|
+
status: "unavailable";
|
|
40
|
+
reason: "Requires the selected verified developer identity.";
|
|
41
|
+
source: "submission-errors";
|
|
42
|
+
}>, Readonly<{
|
|
43
|
+
id: "plugin.submission.archive.path_length";
|
|
44
|
+
status: "unavailable";
|
|
45
|
+
reason: "The public reference does not publish a numeric path-length limit.";
|
|
46
|
+
source: "submission-errors";
|
|
47
|
+
}>, Readonly<{
|
|
48
|
+
id: "plugin.submission.archive.normalization_algorithm";
|
|
49
|
+
status: "unavailable";
|
|
50
|
+
reason: "The portal does not publish its case and Unicode collision algorithm.";
|
|
51
|
+
source: "submission-errors";
|
|
52
|
+
}>, Readonly<{
|
|
53
|
+
id: "plugin.submission.archive.claude_format_normalized";
|
|
54
|
+
status: "manual";
|
|
55
|
+
reason: "The portal owns Claude-format normalization details.";
|
|
56
|
+
source: "submission-errors";
|
|
57
|
+
}>];
|
|
58
|
+
}>;
|