@run402/runtime-kernel 0.1.5 → 0.1.6
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 +4 -2
- package/dist/archive.d.ts +210 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +1137 -0
- package/dist/archive.js.map +1 -0
- package/dist/capabilities.d.ts +30 -2
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +43 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/ports.d.ts +2 -0
- package/dist/ports.d.ts.map +1 -1
- package/package.json +6 -4
- package/schemas/project-archive-auth-stubs.v1.schema.json +21 -0
- package/schemas/project-archive-database-tables.v1.schema.json +50 -0
- package/schemas/project-archive-descriptor.v1.schema.json +50 -0
- package/schemas/project-archive-export-report.v1.schema.json +44 -0
- package/schemas/project-archive-import-result.v1.schema.json +45 -0
- package/schemas/project-archive-index.v1.schema.json +132 -0
- package/schemas/project-archive-layout.v1.schema.json +44 -0
- package/schemas/project-archive-portability-report.v1.schema.json +61 -0
- package/schemas/project-archive-runtime-index.v1.schema.json +42 -0
- package/schemas/project-archive-secret-requirements.v1.schema.json +30 -0
- package/schemas/project-archive-storage-index.v1.schema.json +35 -0
package/dist/archive.js
ADDED
|
@@ -0,0 +1,1137 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, opendir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { canonicalizeJson } from "@run402/release";
|
|
5
|
+
import { RuntimeKernelTypedError } from "./errors.js";
|
|
6
|
+
export const PROJECT_ARCHIVE_VERSION = "run402-project-archive.v1";
|
|
7
|
+
export const PROJECT_ARCHIVE_LAYOUT_SCHEMA_VERSION = "run402.project_archive.layout.v1";
|
|
8
|
+
export const PROJECT_ARCHIVE_INDEX_SCHEMA_VERSION = "run402.project_archive.index.v1";
|
|
9
|
+
export const PROJECT_ARCHIVE_DIGEST_IDENTITY = "run402-project-archive-logical-v1";
|
|
10
|
+
export const PROJECT_ARCHIVE_DEFAULT_EXTENSION = ".r402ar";
|
|
11
|
+
export const PROJECT_ARCHIVE_TRANSPORTS = ["directory", "tar"];
|
|
12
|
+
export const PROJECT_ARCHIVE_MEDIA_TYPES = {
|
|
13
|
+
layout: "application/vnd.run402.project-archive.layout.v1+json",
|
|
14
|
+
index: "application/vnd.run402.project-archive.index.v1+json",
|
|
15
|
+
descriptor: "application/vnd.run402.project-archive.descriptor.v1+json",
|
|
16
|
+
exportReport: "application/vnd.run402.project-archive.export-report.v1+json",
|
|
17
|
+
portabilityReport: "application/vnd.run402.project-archive.portability-report.v1+json",
|
|
18
|
+
consistency: "application/vnd.run402.project-archive.consistency.v1+json",
|
|
19
|
+
databaseTables: "application/vnd.run402.project-archive.database-tables.v1+json",
|
|
20
|
+
databaseSequences: "application/vnd.run402.project-archive.database-sequences.v1+json",
|
|
21
|
+
databaseSql: "application/sql; dialect=postgresql",
|
|
22
|
+
databaseCopy: "application/vnd.postgresql.copy.text",
|
|
23
|
+
authConfig: "application/vnd.run402.project-archive.auth-config.v1+json",
|
|
24
|
+
authSubjects: "application/x-ndjson; profile=run402-auth-subject-stubs-v1",
|
|
25
|
+
storageIndex: "application/vnd.run402.project-archive.storage-index.v1+json",
|
|
26
|
+
runtimeIndex: "application/vnd.run402.project-archive.runtime-index.v1+json",
|
|
27
|
+
secretRequirements: "application/vnd.run402.project-archive.secret-requirements.v1+json",
|
|
28
|
+
envTemplate: "text/plain; profile=run402-required-env-template-v1",
|
|
29
|
+
releaseSpec: "application/vnd.run402.release-spec.v1+json",
|
|
30
|
+
portableReleaseState: "application/vnd.run402.portable-release-state.v1+json",
|
|
31
|
+
releaseFactSet: "application/vnd.run402.release-fact-set.v1+json",
|
|
32
|
+
blob: "application/octet-stream",
|
|
33
|
+
};
|
|
34
|
+
export const SUPPORTED_ARCHIVE_CAPABILITIES = [
|
|
35
|
+
"run402.core.release-state.v1",
|
|
36
|
+
"run402.core.database.phased-postgres-copy.v1",
|
|
37
|
+
"run402.core.storage.cas.v1",
|
|
38
|
+
"run402.core.functions.node22.v1",
|
|
39
|
+
"run402.core.astro-ssr.v1",
|
|
40
|
+
"run402.core.auth-stubs.v1",
|
|
41
|
+
"run402.core.secret-requirements.v1",
|
|
42
|
+
];
|
|
43
|
+
export const ARCHIVE_ERROR_CODES = [
|
|
44
|
+
"EXPORT_CONSISTENCY_UNAVAILABLE",
|
|
45
|
+
"EXPORT_SCOPE_UNSUPPORTED",
|
|
46
|
+
"ARCHIVE_EXPIRED",
|
|
47
|
+
"ARCHIVE_DIGEST_MISMATCH",
|
|
48
|
+
"ARCHIVE_SIZE_MISMATCH",
|
|
49
|
+
"ARCHIVE_UNSUPPORTED_VERSION",
|
|
50
|
+
"ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY",
|
|
51
|
+
"ARCHIVE_MEDIA_TYPE_UNSUPPORTED",
|
|
52
|
+
"ARCHIVE_PATH_UNSAFE",
|
|
53
|
+
"ARCHIVE_SIZE_LIMIT_EXCEEDED",
|
|
54
|
+
"ARCHIVE_FILE_COUNT_LIMIT_EXCEEDED",
|
|
55
|
+
"ARCHIVE_DESCRIPTOR_TOO_DEEP",
|
|
56
|
+
"ARCHIVE_DESCRIPTOR_MISSING",
|
|
57
|
+
"ARCHIVE_BLOB_MISSING",
|
|
58
|
+
"ARCHIVE_DUPLICATE_PATH",
|
|
59
|
+
"ARCHIVE_DUPLICATE_JSON_KEY",
|
|
60
|
+
"ARCHIVE_MALFORMED_JSON",
|
|
61
|
+
"ARCHIVE_MALFORMED_TAR",
|
|
62
|
+
"ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
63
|
+
"DATABASE_EXTENSION_UNSUPPORTED",
|
|
64
|
+
"DATABASE_RLS_IMPORT_UNSUPPORTED",
|
|
65
|
+
"DATABASE_SCHEMA_UNSAFE",
|
|
66
|
+
"DATABASE_SEQUENCE_RESTORE_FAILED",
|
|
67
|
+
"NON_DETERMINISTIC_TABLE_ORDER",
|
|
68
|
+
"STORAGE_OBJECT_CHANGED_DURING_EXPORT",
|
|
69
|
+
"STORAGE_OBJECT_DIGEST_MISMATCH",
|
|
70
|
+
"AUTH_CREDENTIALS_NOT_EXPORTED",
|
|
71
|
+
"AUTH_SUBJECT_STUBS_IMPORTED",
|
|
72
|
+
"SECRET_VALUES_REQUIRED",
|
|
73
|
+
"CLOUD_ONLY_FEATURE_EXCLUDED",
|
|
74
|
+
"PROJECT_ALREADY_EXISTS",
|
|
75
|
+
"IMPORT_VERIFY_FAILED",
|
|
76
|
+
"IMPORT_CONFORMANCE_FAILED",
|
|
77
|
+
];
|
|
78
|
+
export const PORTABILITY_REPORT_SEVERITIES = ["info", "warning", "blocking"];
|
|
79
|
+
export const ARCHIVE_NEXT_ACTION_TYPES = [
|
|
80
|
+
"run_command",
|
|
81
|
+
"set_secret",
|
|
82
|
+
"change_export_scope",
|
|
83
|
+
"remove_unsupported_feature",
|
|
84
|
+
"retry_later",
|
|
85
|
+
"contact_support",
|
|
86
|
+
"read_docs",
|
|
87
|
+
"none",
|
|
88
|
+
];
|
|
89
|
+
export class PortableArchiveError extends RuntimeKernelTypedError {
|
|
90
|
+
constructor(code, message, details = {}) {
|
|
91
|
+
super(code, 422, message, details);
|
|
92
|
+
this.name = "PortableArchiveError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const DEFAULT_ARCHIVE_LIMITS = {
|
|
96
|
+
maxFiles: 20_000,
|
|
97
|
+
maxExpandedBytes: 512 * 1024 * 1024,
|
|
98
|
+
maxFileBytes: 128 * 1024 * 1024,
|
|
99
|
+
maxDescriptorBytes: 2 * 1024 * 1024,
|
|
100
|
+
maxDescriptorDepth: 64,
|
|
101
|
+
};
|
|
102
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
103
|
+
const SHA256_DIGEST_RE = /^sha256:[a-f0-9]{64}$/;
|
|
104
|
+
const TAR_BLOCK_BYTES = 512;
|
|
105
|
+
const CONTROL_RE = /[\x00-\x1f\x7f]/;
|
|
106
|
+
const SUPPORTED_MEDIA_TYPES = new Set(Object.values(PROJECT_ARCHIVE_MEDIA_TYPES));
|
|
107
|
+
const SUPPORTED_CAPABILITY_SET = new Set(SUPPORTED_ARCHIVE_CAPABILITIES);
|
|
108
|
+
export function canonicalizePortableArchiveJson(value) {
|
|
109
|
+
return canonicalizeJson(value);
|
|
110
|
+
}
|
|
111
|
+
export function computePortableArchiveBytesDigest(bytes) {
|
|
112
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
113
|
+
}
|
|
114
|
+
export function computePortableArchiveJsonDigest(value) {
|
|
115
|
+
return computePortableArchiveBytesDigest(Buffer.from(canonicalizePortableArchiveJson(value), "utf8"));
|
|
116
|
+
}
|
|
117
|
+
export function computePortableArchiveLogicalDigest(index) {
|
|
118
|
+
const descriptors = [...index.identity_descriptors].sort().map((name) => {
|
|
119
|
+
const descriptor = index.descriptors[name];
|
|
120
|
+
return {
|
|
121
|
+
name,
|
|
122
|
+
mediaType: descriptor?.mediaType,
|
|
123
|
+
digest: descriptor?.digest,
|
|
124
|
+
size: descriptor?.size,
|
|
125
|
+
path: descriptor?.path,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return computePortableArchiveJsonDigest({
|
|
129
|
+
identity: PROJECT_ARCHIVE_DIGEST_IDENTITY,
|
|
130
|
+
archive_version: index.archive_version,
|
|
131
|
+
core_compatibility: index.core_compatibility,
|
|
132
|
+
required_capabilities: [...index.required_capabilities].sort(),
|
|
133
|
+
consistency: index.consistency ?? null,
|
|
134
|
+
descriptors,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export async function inspectPortableArchive(input) {
|
|
138
|
+
return verifyPortableArchive(input);
|
|
139
|
+
}
|
|
140
|
+
export async function importPortableArchive(ports, input) {
|
|
141
|
+
if (input.target.kind === "existing_project") {
|
|
142
|
+
return importResult({
|
|
143
|
+
status: "failed",
|
|
144
|
+
archiveDigest: null,
|
|
145
|
+
requiredSecrets: [],
|
|
146
|
+
diagnostics: [diagnostic({
|
|
147
|
+
code: "PROJECT_ALREADY_EXISTS",
|
|
148
|
+
resourceType: "project",
|
|
149
|
+
resourceId: input.target.project_id,
|
|
150
|
+
message: "Portable archive v1 imports into a new local Core project only.",
|
|
151
|
+
nextAction: {
|
|
152
|
+
type: "run_command",
|
|
153
|
+
message: "Choose a new project name and retry import.",
|
|
154
|
+
},
|
|
155
|
+
})],
|
|
156
|
+
nextAction: {
|
|
157
|
+
type: "run_command",
|
|
158
|
+
message: "Retry with target.kind = 'new_project'.",
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const projectName = normalizeImportProjectName(input.target.name);
|
|
163
|
+
const verification = await verifyPortableArchive({
|
|
164
|
+
archivePath: input.archivePath,
|
|
165
|
+
limits: input.limits,
|
|
166
|
+
});
|
|
167
|
+
if (!verification.ok) {
|
|
168
|
+
return importResult({
|
|
169
|
+
status: "failed",
|
|
170
|
+
archiveDigest: verification.archive_digest,
|
|
171
|
+
requiredSecrets: verification.required_secrets,
|
|
172
|
+
diagnostics: [
|
|
173
|
+
diagnostic({
|
|
174
|
+
code: "IMPORT_VERIFY_FAILED",
|
|
175
|
+
resourceType: "archive",
|
|
176
|
+
message: "Portable archive verification failed; Core state was not mutated.",
|
|
177
|
+
context: { diagnostic_count: verification.diagnostics.length },
|
|
178
|
+
}),
|
|
179
|
+
...verification.diagnostics,
|
|
180
|
+
],
|
|
181
|
+
nextAction: {
|
|
182
|
+
type: "run_command",
|
|
183
|
+
message: "Run archive verify, fix the reported archive issue, then retry import.",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const secretValues = input.secretValues ?? {};
|
|
188
|
+
const missingSecrets = verification.required_secrets.filter((secret) => secret.required && !(secret.name in secretValues));
|
|
189
|
+
if (input.requireRunnable && missingSecrets.length > 0) {
|
|
190
|
+
return importResult({
|
|
191
|
+
status: "blocked",
|
|
192
|
+
archiveDigest: verification.archive_digest,
|
|
193
|
+
projectName,
|
|
194
|
+
requiredSecrets: verification.required_secrets,
|
|
195
|
+
diagnostics: missingSecrets.map((secret) => diagnostic({
|
|
196
|
+
code: "SECRET_VALUES_REQUIRED",
|
|
197
|
+
resourceType: "secret",
|
|
198
|
+
resourceId: secret.name,
|
|
199
|
+
message: `Required secret ${secret.name} is missing.`,
|
|
200
|
+
nextAction: {
|
|
201
|
+
type: "set_secret",
|
|
202
|
+
env_var: secret.name,
|
|
203
|
+
message: "Set this secret and retry import.",
|
|
204
|
+
},
|
|
205
|
+
retryable: true,
|
|
206
|
+
})),
|
|
207
|
+
nextAction: {
|
|
208
|
+
type: "set_secret",
|
|
209
|
+
message: "Provide all required secret values or retry without requireRunnable.",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (input.dryRun) {
|
|
214
|
+
return importResult({
|
|
215
|
+
status: "dry_run",
|
|
216
|
+
archiveDigest: verification.archive_digest,
|
|
217
|
+
projectName,
|
|
218
|
+
requiredSecrets: verification.required_secrets,
|
|
219
|
+
diagnostics: verification.diagnostics,
|
|
220
|
+
nextAction: {
|
|
221
|
+
type: "run_command",
|
|
222
|
+
message: "Run import without dryRun to create the new local Core project.",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const imported = await ports.importer.importVerifiedArchive({
|
|
227
|
+
archive_path: input.archivePath,
|
|
228
|
+
project_name: projectName,
|
|
229
|
+
verification,
|
|
230
|
+
secret_values: secretValues,
|
|
231
|
+
require_runnable: input.requireRunnable ?? false,
|
|
232
|
+
});
|
|
233
|
+
return importResult({
|
|
234
|
+
status: "imported",
|
|
235
|
+
archiveDigest: verification.archive_digest,
|
|
236
|
+
projectId: imported.project_id,
|
|
237
|
+
projectName: imported.project_name,
|
|
238
|
+
releaseId: imported.release_id,
|
|
239
|
+
requiredSecrets: verification.required_secrets,
|
|
240
|
+
diagnostics: verification.diagnostics,
|
|
241
|
+
nextAction: { type: "none" },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
export async function verifyPortableArchive(input) {
|
|
245
|
+
const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...(input.limits ?? {}) };
|
|
246
|
+
const diagnostics = [];
|
|
247
|
+
let archive;
|
|
248
|
+
try {
|
|
249
|
+
archive = await readArchiveEntries(input.archivePath, limits);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
return emptyVerifyResult(errorToDiagnostic(error), null);
|
|
253
|
+
}
|
|
254
|
+
const layout = parseJsonEntry(archive, "run402-layout.json", diagnostics, limits);
|
|
255
|
+
const index = parseJsonEntry(archive, "index.json", diagnostics, limits);
|
|
256
|
+
let archiveDigest = null;
|
|
257
|
+
let requiredSecrets = [];
|
|
258
|
+
let authSubjectStubCount = 0;
|
|
259
|
+
let exportReport = null;
|
|
260
|
+
let portabilityReport = null;
|
|
261
|
+
if (layout) {
|
|
262
|
+
validateLayout(layout, diagnostics);
|
|
263
|
+
}
|
|
264
|
+
if (index) {
|
|
265
|
+
validateIndex(index, diagnostics);
|
|
266
|
+
verifyRequiredCapabilities(index, diagnostics);
|
|
267
|
+
verifyDescriptors(index, archive, diagnostics, limits);
|
|
268
|
+
archiveDigest = computePortableArchiveLogicalDigest(index);
|
|
269
|
+
if (index.archive_digest && index.archive_digest !== archiveDigest) {
|
|
270
|
+
diagnostics.push(diagnostic({
|
|
271
|
+
code: "ARCHIVE_DIGEST_MISMATCH",
|
|
272
|
+
path: "index.json",
|
|
273
|
+
resourceType: "archive",
|
|
274
|
+
message: `Archive logical digest mismatch: expected ${index.archive_digest}, computed ${archiveDigest}.`,
|
|
275
|
+
context: { expected_digest: index.archive_digest, actual_digest: archiveDigest },
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
exportReport = parseOptionalJsonDescriptor(index, archive, "export_report", diagnostics, limits);
|
|
279
|
+
portabilityReport = parseOptionalJsonDescriptor(index, archive, "portability_report", diagnostics, limits);
|
|
280
|
+
const secretRequirements = parseOptionalJsonDescriptor(index, archive, "secret_requirements", diagnostics, limits);
|
|
281
|
+
requiredSecrets = Array.isArray(secretRequirements?.secrets)
|
|
282
|
+
? secretRequirements.secrets.filter(isArchiveSecretRequirement)
|
|
283
|
+
: [];
|
|
284
|
+
authSubjectStubCount = countAuthSubjectStubs(index, archive, diagnostics, limits);
|
|
285
|
+
addDatabaseOrderDiagnostics(index, archive, diagnostics, limits);
|
|
286
|
+
diagnostics.push(...portabilityDiagnostics(portabilityReport));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
diagnostics.push(diagnostic({
|
|
290
|
+
code: "ARCHIVE_DESCRIPTOR_MISSING",
|
|
291
|
+
path: "index.json",
|
|
292
|
+
resourceType: "archive",
|
|
293
|
+
message: "Portable archive is missing index.json.",
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
const blocking = diagnostics.some((entry) => entry.severity === "blocking");
|
|
297
|
+
return {
|
|
298
|
+
ok: !blocking,
|
|
299
|
+
archive_version: index?.archive_version === PROJECT_ARCHIVE_VERSION ? PROJECT_ARCHIVE_VERSION : null,
|
|
300
|
+
archive_digest: archiveDigest,
|
|
301
|
+
transport: archive.transport,
|
|
302
|
+
file_count: archive.entries.size,
|
|
303
|
+
total_bytes: archive.totalBytes,
|
|
304
|
+
descriptor_count: index ? Object.keys(index.descriptors ?? {}).length : 0,
|
|
305
|
+
required_capabilities: Array.isArray(index?.required_capabilities) ? index.required_capabilities : [],
|
|
306
|
+
required_secrets: requiredSecrets,
|
|
307
|
+
auth_subject_stub_count: authSubjectStubCount,
|
|
308
|
+
export_report: exportReport,
|
|
309
|
+
portability_report: portabilityReport,
|
|
310
|
+
diagnostics,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function importResult(input) {
|
|
314
|
+
return {
|
|
315
|
+
schema_version: "run402.project_archive.import_result.v1",
|
|
316
|
+
status: input.status,
|
|
317
|
+
archive_digest: input.archiveDigest,
|
|
318
|
+
...(input.projectId ? { project_id: input.projectId } : {}),
|
|
319
|
+
...(input.projectName ? { project_name: input.projectName } : {}),
|
|
320
|
+
...(input.releaseId !== undefined ? { release_id: input.releaseId } : {}),
|
|
321
|
+
required_secrets: input.requiredSecrets,
|
|
322
|
+
diagnostics: input.diagnostics,
|
|
323
|
+
next_action: input.nextAction,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
function normalizeImportProjectName(value) {
|
|
327
|
+
const name = value?.trim();
|
|
328
|
+
if (!name)
|
|
329
|
+
return "imported-project";
|
|
330
|
+
if (name.length > 120) {
|
|
331
|
+
throw new RangeError("Project name must be 120 characters or less.");
|
|
332
|
+
}
|
|
333
|
+
return name;
|
|
334
|
+
}
|
|
335
|
+
function validateLayout(layout, diagnostics) {
|
|
336
|
+
if (layout.schema_version !== PROJECT_ARCHIVE_LAYOUT_SCHEMA_VERSION || layout.archive_version !== PROJECT_ARCHIVE_VERSION) {
|
|
337
|
+
diagnostics.push(diagnostic({
|
|
338
|
+
code: "ARCHIVE_UNSUPPORTED_VERSION",
|
|
339
|
+
path: "run402-layout.json",
|
|
340
|
+
resourceType: "layout",
|
|
341
|
+
message: "Archive layout version is not supported by this Core runtime.",
|
|
342
|
+
context: {
|
|
343
|
+
schema_version: layout.schema_version,
|
|
344
|
+
archive_version: layout.archive_version,
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
if (layout.mediaType !== PROJECT_ARCHIVE_MEDIA_TYPES.layout) {
|
|
349
|
+
diagnostics.push(diagnostic({
|
|
350
|
+
code: "ARCHIVE_MEDIA_TYPE_UNSUPPORTED",
|
|
351
|
+
path: "run402-layout.json",
|
|
352
|
+
resourceType: "layout",
|
|
353
|
+
message: "Archive layout media type is not supported.",
|
|
354
|
+
context: { mediaType: layout.mediaType },
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
if (layout.index !== "index.json" || layout.blobs !== "blobs/sha256" || layout.checksum_lists_authoritative !== false) {
|
|
358
|
+
diagnostics.push(diagnostic({
|
|
359
|
+
code: "ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY",
|
|
360
|
+
path: "run402-layout.json",
|
|
361
|
+
resourceType: "layout",
|
|
362
|
+
message: "Archive layout uses an unsupported root or authoritative checksum-list mode.",
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function validateIndex(index, diagnostics) {
|
|
367
|
+
if (index.schema_version !== PROJECT_ARCHIVE_INDEX_SCHEMA_VERSION || index.archive_version !== PROJECT_ARCHIVE_VERSION) {
|
|
368
|
+
diagnostics.push(diagnostic({
|
|
369
|
+
code: "ARCHIVE_UNSUPPORTED_VERSION",
|
|
370
|
+
path: "index.json",
|
|
371
|
+
resourceType: "index",
|
|
372
|
+
message: "Archive index version is not supported by this Core runtime.",
|
|
373
|
+
context: {
|
|
374
|
+
schema_version: index.schema_version,
|
|
375
|
+
archive_version: index.archive_version,
|
|
376
|
+
},
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
if (index.mediaType !== PROJECT_ARCHIVE_MEDIA_TYPES.index) {
|
|
380
|
+
diagnostics.push(diagnostic({
|
|
381
|
+
code: "ARCHIVE_MEDIA_TYPE_UNSUPPORTED",
|
|
382
|
+
path: "index.json",
|
|
383
|
+
resourceType: "index",
|
|
384
|
+
message: "Archive index media type is not supported.",
|
|
385
|
+
context: { mediaType: index.mediaType },
|
|
386
|
+
}));
|
|
387
|
+
}
|
|
388
|
+
if (!Array.isArray(index.identity_descriptors) || !isRecord(index.descriptors)) {
|
|
389
|
+
diagnostics.push(diagnostic({
|
|
390
|
+
code: "ARCHIVE_MALFORMED_JSON",
|
|
391
|
+
path: "index.json",
|
|
392
|
+
resourceType: "index",
|
|
393
|
+
message: "Archive index must contain identity_descriptors and descriptors.",
|
|
394
|
+
}));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
for (const name of index.identity_descriptors) {
|
|
398
|
+
if (typeof name !== "string" || !index.descriptors[name]) {
|
|
399
|
+
diagnostics.push(diagnostic({
|
|
400
|
+
code: "ARCHIVE_DESCRIPTOR_MISSING",
|
|
401
|
+
path: "index.json",
|
|
402
|
+
resourceType: "descriptor",
|
|
403
|
+
resourceId: typeof name === "string" ? name : undefined,
|
|
404
|
+
message: "Archive identity descriptor is not present in descriptors.",
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function verifyRequiredCapabilities(index, diagnostics) {
|
|
410
|
+
if (!Array.isArray(index.required_capabilities)) {
|
|
411
|
+
diagnostics.push(diagnostic({
|
|
412
|
+
code: "ARCHIVE_MALFORMED_JSON",
|
|
413
|
+
path: "index.json",
|
|
414
|
+
resourceType: "capability",
|
|
415
|
+
message: "Archive required_capabilities must be an array.",
|
|
416
|
+
}));
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
for (const capability of index.required_capabilities) {
|
|
420
|
+
if (typeof capability !== "string" || !SUPPORTED_CAPABILITY_SET.has(capability)) {
|
|
421
|
+
diagnostics.push(diagnostic({
|
|
422
|
+
code: "ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY",
|
|
423
|
+
path: "index.json",
|
|
424
|
+
resourceType: "capability",
|
|
425
|
+
resourceId: typeof capability === "string" ? capability : undefined,
|
|
426
|
+
message: `Archive requires unsupported capability: ${String(capability)}.`,
|
|
427
|
+
nextAction: {
|
|
428
|
+
type: "read_docs",
|
|
429
|
+
message: "Use a newer Run402 Core runtime or export a narrower supported archive slice.",
|
|
430
|
+
},
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function verifyDescriptors(index, archive, diagnostics, limits) {
|
|
436
|
+
for (const [name, descriptor] of Object.entries(index.descriptors ?? {})) {
|
|
437
|
+
if (!isDescriptor(descriptor)) {
|
|
438
|
+
diagnostics.push(diagnostic({
|
|
439
|
+
code: "ARCHIVE_MALFORMED_JSON",
|
|
440
|
+
path: "index.json",
|
|
441
|
+
resourceType: "descriptor",
|
|
442
|
+
resourceId: name,
|
|
443
|
+
message: "Archive descriptor has an invalid shape.",
|
|
444
|
+
}));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (!SUPPORTED_MEDIA_TYPES.has(descriptor.mediaType)) {
|
|
448
|
+
diagnostics.push(diagnostic({
|
|
449
|
+
code: "ARCHIVE_MEDIA_TYPE_UNSUPPORTED",
|
|
450
|
+
path: descriptor.path ?? "index.json",
|
|
451
|
+
resourceType: "descriptor",
|
|
452
|
+
resourceId: name,
|
|
453
|
+
message: `Archive descriptor media type is not supported: ${descriptor.mediaType}.`,
|
|
454
|
+
nextAction: {
|
|
455
|
+
type: "read_docs",
|
|
456
|
+
message: "Use a newer Run402 Core runtime or export a narrower supported archive slice.",
|
|
457
|
+
},
|
|
458
|
+
}));
|
|
459
|
+
}
|
|
460
|
+
if (!SHA256_DIGEST_RE.test(descriptor.digest)) {
|
|
461
|
+
diagnostics.push(diagnostic({
|
|
462
|
+
code: "ARCHIVE_MALFORMED_JSON",
|
|
463
|
+
path: descriptor.path ?? "index.json",
|
|
464
|
+
resourceType: "descriptor",
|
|
465
|
+
resourceId: name,
|
|
466
|
+
message: "Archive descriptor digest must be sha256:<64 lowercase hex>.",
|
|
467
|
+
}));
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (descriptor.path) {
|
|
471
|
+
const pathDiagnostic = validateArchivePath(descriptor.path, "descriptor", name);
|
|
472
|
+
if (pathDiagnostic) {
|
|
473
|
+
diagnostics.push(pathDiagnostic);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const bytes = archive.entries.get(descriptor.path);
|
|
477
|
+
if (!bytes) {
|
|
478
|
+
diagnostics.push(diagnostic({
|
|
479
|
+
code: descriptor.path.startsWith("blobs/sha256/") ? "ARCHIVE_BLOB_MISSING" : "ARCHIVE_DESCRIPTOR_MISSING",
|
|
480
|
+
path: descriptor.path,
|
|
481
|
+
resourceType: "descriptor",
|
|
482
|
+
resourceId: name,
|
|
483
|
+
message: `Archive descriptor path is missing: ${descriptor.path}.`,
|
|
484
|
+
}));
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (bytes.byteLength !== descriptor.size) {
|
|
488
|
+
diagnostics.push(diagnostic({
|
|
489
|
+
code: "ARCHIVE_SIZE_MISMATCH",
|
|
490
|
+
path: descriptor.path,
|
|
491
|
+
resourceType: "descriptor",
|
|
492
|
+
resourceId: name,
|
|
493
|
+
message: `Archive descriptor size mismatch for ${descriptor.path}.`,
|
|
494
|
+
context: { expected_size: descriptor.size, actual_size: bytes.byteLength },
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
const actualDigest = computePortableArchiveBytesDigest(bytes);
|
|
498
|
+
if (actualDigest !== descriptor.digest) {
|
|
499
|
+
diagnostics.push(diagnostic({
|
|
500
|
+
code: "ARCHIVE_DIGEST_MISMATCH",
|
|
501
|
+
path: descriptor.path,
|
|
502
|
+
resourceType: "descriptor",
|
|
503
|
+
resourceId: name,
|
|
504
|
+
message: `Archive descriptor digest mismatch for ${descriptor.path}.`,
|
|
505
|
+
context: { expected_digest: descriptor.digest, actual_digest: actualDigest },
|
|
506
|
+
}));
|
|
507
|
+
}
|
|
508
|
+
if (isJsonMediaType(descriptor.mediaType)) {
|
|
509
|
+
const parsed = parseJsonBytes(bytes, descriptor.path, diagnostics, limits);
|
|
510
|
+
if (parsed) {
|
|
511
|
+
const depth = jsonDepth(parsed);
|
|
512
|
+
if (depth > limits.maxDescriptorDepth) {
|
|
513
|
+
diagnostics.push(diagnostic({
|
|
514
|
+
code: "ARCHIVE_DESCRIPTOR_TOO_DEEP",
|
|
515
|
+
path: descriptor.path,
|
|
516
|
+
resourceType: "descriptor",
|
|
517
|
+
resourceId: name,
|
|
518
|
+
message: `Archive descriptor JSON depth ${depth} exceeds limit ${limits.maxDescriptorDepth}.`,
|
|
519
|
+
context: { depth, max_depth: limits.maxDescriptorDepth },
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else if (descriptor.mediaType === PROJECT_ARCHIVE_MEDIA_TYPES.authSubjects) {
|
|
525
|
+
parseNdjsonBytes(bytes, descriptor.path, diagnostics, limits);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
function parseOptionalJsonDescriptor(index, archive, name, diagnostics, limits) {
|
|
531
|
+
const descriptor = index.descriptors[name];
|
|
532
|
+
if (!descriptor?.path)
|
|
533
|
+
return null;
|
|
534
|
+
const bytes = archive.entries.get(descriptor.path);
|
|
535
|
+
if (!bytes)
|
|
536
|
+
return null;
|
|
537
|
+
return parseJsonBytes(bytes, descriptor.path, diagnostics, limits);
|
|
538
|
+
}
|
|
539
|
+
function countAuthSubjectStubs(index, archive, diagnostics, limits) {
|
|
540
|
+
const descriptor = index.descriptors["auth.subjects"];
|
|
541
|
+
if (!descriptor?.path)
|
|
542
|
+
return 0;
|
|
543
|
+
const bytes = archive.entries.get(descriptor.path);
|
|
544
|
+
if (!bytes)
|
|
545
|
+
return 0;
|
|
546
|
+
return parseNdjsonBytes(bytes, descriptor.path, diagnostics, limits);
|
|
547
|
+
}
|
|
548
|
+
function addDatabaseOrderDiagnostics(index, archive, diagnostics, limits) {
|
|
549
|
+
const tables = parseOptionalJsonDescriptor(index, archive, "database.tables", diagnostics, limits);
|
|
550
|
+
if (!Array.isArray(tables?.tables))
|
|
551
|
+
return;
|
|
552
|
+
const deterministicIdentityRequired = index.annotations?.deterministic_identity === "required";
|
|
553
|
+
for (const table of tables.tables) {
|
|
554
|
+
if (!isRecord(table) || table.deterministic_order !== false)
|
|
555
|
+
continue;
|
|
556
|
+
const tableId = typeof table.id === "string"
|
|
557
|
+
? table.id
|
|
558
|
+
: typeof table.name === "string"
|
|
559
|
+
? table.name
|
|
560
|
+
: undefined;
|
|
561
|
+
diagnostics.push(diagnostic({
|
|
562
|
+
code: "NON_DETERMINISTIC_TABLE_ORDER",
|
|
563
|
+
severity: deterministicIdentityRequired ? "blocking" : "warning",
|
|
564
|
+
path: "database/tables.json",
|
|
565
|
+
resourceType: "database_table",
|
|
566
|
+
resourceId: tableId,
|
|
567
|
+
message: deterministicIdentityRequired
|
|
568
|
+
? "Archive table data is not deterministically ordered, but deterministic identity is required."
|
|
569
|
+
: "Archive table data is not deterministically ordered; import can proceed but logical identity may vary across exports.",
|
|
570
|
+
nextAction: deterministicIdentityRequired
|
|
571
|
+
? { type: "change_export_scope", message: "Re-export with deterministic table ordering or disable deterministic identity mode." }
|
|
572
|
+
: { type: "none" },
|
|
573
|
+
retryable: deterministicIdentityRequired,
|
|
574
|
+
context: {
|
|
575
|
+
deterministic_identity_required: deterministicIdentityRequired,
|
|
576
|
+
order_by: Array.isArray(table.order_by) ? table.order_by : null,
|
|
577
|
+
},
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function portabilityDiagnostics(report) {
|
|
582
|
+
if (!report || !Array.isArray(report.entries))
|
|
583
|
+
return [];
|
|
584
|
+
return report.entries
|
|
585
|
+
.filter((entry) => entry.severity === "blocking")
|
|
586
|
+
.map((entry) => ({
|
|
587
|
+
code: entry.code,
|
|
588
|
+
severity: entry.severity,
|
|
589
|
+
resource_type: entry.resource_type,
|
|
590
|
+
...(entry.resource_id ? { resource_id: entry.resource_id } : {}),
|
|
591
|
+
...(entry.path ? { path: entry.path } : {}),
|
|
592
|
+
message: entry.message,
|
|
593
|
+
next_action: entry.next_action,
|
|
594
|
+
retryable: entry.retryable,
|
|
595
|
+
...(entry.context ? { context: entry.context } : {}),
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
async function readArchiveEntries(archivePath, limits) {
|
|
599
|
+
const stats = await lstat(archivePath);
|
|
600
|
+
if (stats.isSymbolicLink()) {
|
|
601
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
602
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
603
|
+
path: archivePath,
|
|
604
|
+
resourceType: "archive",
|
|
605
|
+
message: "Archive root must not be a symbolic link.",
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
if (stats.isDirectory()) {
|
|
609
|
+
return readDirectoryArchive(archivePath, limits);
|
|
610
|
+
}
|
|
611
|
+
if (stats.isFile()) {
|
|
612
|
+
return readTarArchive(archivePath, limits);
|
|
613
|
+
}
|
|
614
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
615
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
616
|
+
path: archivePath,
|
|
617
|
+
resourceType: "archive",
|
|
618
|
+
message: "Archive root must be a directory or uncompressed tar file.",
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
async function readDirectoryArchive(root, limits) {
|
|
622
|
+
const entries = new Map();
|
|
623
|
+
let totalBytes = 0;
|
|
624
|
+
async function visit(dir) {
|
|
625
|
+
const handle = await opendir(dir);
|
|
626
|
+
for await (const dirent of handle) {
|
|
627
|
+
const absolutePath = path.join(dir, dirent.name);
|
|
628
|
+
const relativePath = toArchiveRelativePath(root, absolutePath);
|
|
629
|
+
const pathDiagnostic = validateArchivePath(relativePath, "file");
|
|
630
|
+
if (pathDiagnostic)
|
|
631
|
+
throw new PortableArchiveReadError(pathDiagnostic);
|
|
632
|
+
const stats = await lstat(absolutePath);
|
|
633
|
+
if (stats.isSymbolicLink()) {
|
|
634
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
635
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
636
|
+
path: relativePath,
|
|
637
|
+
resourceType: "file",
|
|
638
|
+
message: "Archive entries must not be symbolic links.",
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
if (stats.isDirectory()) {
|
|
642
|
+
await visit(absolutePath);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (!stats.isFile()) {
|
|
646
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
647
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
648
|
+
path: relativePath,
|
|
649
|
+
resourceType: "file",
|
|
650
|
+
message: "Archive entries must be regular files.",
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
if (stats.nlink > 1) {
|
|
654
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
655
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
656
|
+
path: relativePath,
|
|
657
|
+
resourceType: "file",
|
|
658
|
+
message: "Archive entries must not be hardlinks.",
|
|
659
|
+
}));
|
|
660
|
+
}
|
|
661
|
+
if (entries.has(relativePath)) {
|
|
662
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
663
|
+
code: "ARCHIVE_DUPLICATE_PATH",
|
|
664
|
+
path: relativePath,
|
|
665
|
+
resourceType: "file",
|
|
666
|
+
message: "Archive contains a duplicate path.",
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
checkArchiveLimits(entries.size + 1, totalBytes + stats.size, stats.size, relativePath, limits);
|
|
670
|
+
entries.set(relativePath, await readFile(absolutePath));
|
|
671
|
+
totalBytes += stats.size;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
await visit(root);
|
|
675
|
+
return { entries, transport: "directory", totalBytes };
|
|
676
|
+
}
|
|
677
|
+
async function readTarArchive(tarPath, limits) {
|
|
678
|
+
const tarBytes = await readFile(tarPath);
|
|
679
|
+
if (tarBytes[0] === 0x1f && tarBytes[1] === 0x8b) {
|
|
680
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
681
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
682
|
+
path: tarPath,
|
|
683
|
+
resourceType: "tar",
|
|
684
|
+
message: "Compressed archive envelopes are not supported in v1; Core verifies directory trees and uncompressed tar only.",
|
|
685
|
+
nextAction: {
|
|
686
|
+
type: "run_command",
|
|
687
|
+
command: "tar -xf project.r402ar -C ./project.r402ar.dir && run402 archives verify ./project.r402ar.dir --json",
|
|
688
|
+
},
|
|
689
|
+
}));
|
|
690
|
+
}
|
|
691
|
+
const entries = new Map();
|
|
692
|
+
let offset = 0;
|
|
693
|
+
let totalBytes = 0;
|
|
694
|
+
while (offset + TAR_BLOCK_BYTES <= tarBytes.byteLength) {
|
|
695
|
+
const header = tarBytes.subarray(offset, offset + TAR_BLOCK_BYTES);
|
|
696
|
+
if (isZeroBlock(header))
|
|
697
|
+
break;
|
|
698
|
+
const name = readTarString(header, 0, 100);
|
|
699
|
+
const prefix = readTarString(header, 345, 155);
|
|
700
|
+
const entryPath = prefix ? `${prefix}/${name}` : name;
|
|
701
|
+
const typeFlag = header[156];
|
|
702
|
+
const size = parseTarSize(readTarString(header, 124, 12), entryPath);
|
|
703
|
+
const pathDiagnostic = validateArchivePath(entryPath, "file");
|
|
704
|
+
if (pathDiagnostic)
|
|
705
|
+
throw new PortableArchiveReadError(pathDiagnostic);
|
|
706
|
+
const dataOffset = offset + TAR_BLOCK_BYTES;
|
|
707
|
+
const paddedSize = Math.ceil(size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES;
|
|
708
|
+
const nextOffset = dataOffset + paddedSize;
|
|
709
|
+
if (nextOffset > tarBytes.byteLength) {
|
|
710
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
711
|
+
code: "ARCHIVE_MALFORMED_TAR",
|
|
712
|
+
path: entryPath,
|
|
713
|
+
resourceType: "tar",
|
|
714
|
+
message: "Tar entry exceeds archive file length.",
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
if (typeFlag === 53) {
|
|
718
|
+
offset = nextOffset;
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
if (typeFlag !== 0 && typeFlag !== 48) {
|
|
722
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
723
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
724
|
+
path: entryPath,
|
|
725
|
+
resourceType: "tar",
|
|
726
|
+
message: "Portable archives only support regular files and directories in tar transport.",
|
|
727
|
+
context: { type_flag: String.fromCharCode(typeFlag) },
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
if (entries.has(entryPath)) {
|
|
731
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
732
|
+
code: "ARCHIVE_DUPLICATE_PATH",
|
|
733
|
+
path: entryPath,
|
|
734
|
+
resourceType: "tar",
|
|
735
|
+
message: "Tar archive contains a duplicate path.",
|
|
736
|
+
}));
|
|
737
|
+
}
|
|
738
|
+
checkArchiveLimits(entries.size + 1, totalBytes + size, size, entryPath, limits);
|
|
739
|
+
entries.set(entryPath, tarBytes.subarray(dataOffset, dataOffset + size));
|
|
740
|
+
totalBytes += size;
|
|
741
|
+
offset = nextOffset;
|
|
742
|
+
}
|
|
743
|
+
return { entries, transport: "tar", totalBytes };
|
|
744
|
+
}
|
|
745
|
+
function parseJsonEntry(archive, entryPath, diagnostics, limits) {
|
|
746
|
+
const bytes = archive.entries.get(entryPath);
|
|
747
|
+
if (!bytes)
|
|
748
|
+
return null;
|
|
749
|
+
return parseJsonBytes(bytes, entryPath, diagnostics, limits);
|
|
750
|
+
}
|
|
751
|
+
function parseJsonBytes(bytes, entryPath, diagnostics, limits) {
|
|
752
|
+
if (bytes.byteLength > limits.maxDescriptorBytes) {
|
|
753
|
+
diagnostics.push(diagnostic({
|
|
754
|
+
code: "ARCHIVE_SIZE_LIMIT_EXCEEDED",
|
|
755
|
+
path: entryPath,
|
|
756
|
+
resourceType: "descriptor",
|
|
757
|
+
message: `JSON descriptor exceeds limit ${limits.maxDescriptorBytes}.`,
|
|
758
|
+
context: { max_descriptor_bytes: limits.maxDescriptorBytes, actual_bytes: bytes.byteLength },
|
|
759
|
+
}));
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const text = UTF8.decode(bytes);
|
|
764
|
+
assertJsonHasNoDuplicateKeys(text, entryPath);
|
|
765
|
+
return JSON.parse(text);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
const failure = normalizeJsonFailure(error);
|
|
769
|
+
diagnostics.push(diagnostic({
|
|
770
|
+
code: failure.code,
|
|
771
|
+
path: entryPath,
|
|
772
|
+
resourceType: "descriptor",
|
|
773
|
+
message: failure.message,
|
|
774
|
+
context: failure.details,
|
|
775
|
+
}));
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function parseNdjsonBytes(bytes, entryPath, diagnostics, limits) {
|
|
780
|
+
if (bytes.byteLength > limits.maxDescriptorBytes) {
|
|
781
|
+
diagnostics.push(diagnostic({
|
|
782
|
+
code: "ARCHIVE_SIZE_LIMIT_EXCEEDED",
|
|
783
|
+
path: entryPath,
|
|
784
|
+
resourceType: "descriptor",
|
|
785
|
+
message: `NDJSON descriptor exceeds limit ${limits.maxDescriptorBytes}.`,
|
|
786
|
+
}));
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
const text = UTF8.decode(bytes);
|
|
791
|
+
let count = 0;
|
|
792
|
+
for (const line of text.split(/\r?\n/)) {
|
|
793
|
+
if (!line.trim())
|
|
794
|
+
continue;
|
|
795
|
+
assertJsonHasNoDuplicateKeys(line, entryPath);
|
|
796
|
+
JSON.parse(line);
|
|
797
|
+
count += 1;
|
|
798
|
+
}
|
|
799
|
+
return count;
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
const failure = normalizeJsonFailure(error);
|
|
803
|
+
diagnostics.push(diagnostic({
|
|
804
|
+
code: failure.code,
|
|
805
|
+
path: entryPath,
|
|
806
|
+
resourceType: "descriptor",
|
|
807
|
+
message: failure.message,
|
|
808
|
+
context: failure.details,
|
|
809
|
+
}));
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
function assertJsonHasNoDuplicateKeys(text, entryPath) {
|
|
814
|
+
new JsonDuplicateKeyScanner(text, entryPath).parse();
|
|
815
|
+
}
|
|
816
|
+
class JsonDuplicateKeyScanner {
|
|
817
|
+
text;
|
|
818
|
+
entryPath;
|
|
819
|
+
#index = 0;
|
|
820
|
+
constructor(text, entryPath) {
|
|
821
|
+
this.text = text;
|
|
822
|
+
this.entryPath = entryPath;
|
|
823
|
+
}
|
|
824
|
+
parse() {
|
|
825
|
+
this.#skipWhitespace();
|
|
826
|
+
this.#parseValue();
|
|
827
|
+
this.#skipWhitespace();
|
|
828
|
+
if (this.#index !== this.text.length) {
|
|
829
|
+
throw jsonFailure("ARCHIVE_MALFORMED_JSON", `Malformed JSON in ${this.entryPath}.`, {
|
|
830
|
+
offset: this.#index,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
#parseValue() {
|
|
835
|
+
this.#skipWhitespace();
|
|
836
|
+
const char = this.text[this.#index];
|
|
837
|
+
if (char === "{") {
|
|
838
|
+
this.#parseObject();
|
|
839
|
+
}
|
|
840
|
+
else if (char === "[") {
|
|
841
|
+
this.#parseArray();
|
|
842
|
+
}
|
|
843
|
+
else if (char === "\"") {
|
|
844
|
+
this.#parseStringToken();
|
|
845
|
+
}
|
|
846
|
+
else if (char === "-" || (char >= "0" && char <= "9")) {
|
|
847
|
+
this.#parseNumber();
|
|
848
|
+
}
|
|
849
|
+
else if (this.text.startsWith("true", this.#index)) {
|
|
850
|
+
this.#index += 4;
|
|
851
|
+
}
|
|
852
|
+
else if (this.text.startsWith("false", this.#index)) {
|
|
853
|
+
this.#index += 5;
|
|
854
|
+
}
|
|
855
|
+
else if (this.text.startsWith("null", this.#index)) {
|
|
856
|
+
this.#index += 4;
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
throw jsonFailure("ARCHIVE_MALFORMED_JSON", `Malformed JSON in ${this.entryPath}.`, {
|
|
860
|
+
offset: this.#index,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
#parseObject() {
|
|
865
|
+
const keys = new Set();
|
|
866
|
+
this.#expect("{");
|
|
867
|
+
this.#skipWhitespace();
|
|
868
|
+
if (this.#peek("}")) {
|
|
869
|
+
this.#index += 1;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
while (true) {
|
|
873
|
+
this.#skipWhitespace();
|
|
874
|
+
const key = this.#parseStringToken();
|
|
875
|
+
if (keys.has(key)) {
|
|
876
|
+
throw jsonFailure("ARCHIVE_DUPLICATE_JSON_KEY", `Duplicate JSON key "${key}" in ${this.entryPath}.`, {
|
|
877
|
+
key,
|
|
878
|
+
offset: this.#index,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
keys.add(key);
|
|
882
|
+
this.#skipWhitespace();
|
|
883
|
+
this.#expect(":");
|
|
884
|
+
this.#parseValue();
|
|
885
|
+
this.#skipWhitespace();
|
|
886
|
+
if (this.#peek("}")) {
|
|
887
|
+
this.#index += 1;
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
this.#expect(",");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
#parseArray() {
|
|
894
|
+
this.#expect("[");
|
|
895
|
+
this.#skipWhitespace();
|
|
896
|
+
if (this.#peek("]")) {
|
|
897
|
+
this.#index += 1;
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
while (true) {
|
|
901
|
+
this.#parseValue();
|
|
902
|
+
this.#skipWhitespace();
|
|
903
|
+
if (this.#peek("]")) {
|
|
904
|
+
this.#index += 1;
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
this.#expect(",");
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
#parseStringToken() {
|
|
911
|
+
const start = this.#index;
|
|
912
|
+
this.#expect("\"");
|
|
913
|
+
while (this.#index < this.text.length) {
|
|
914
|
+
const char = this.text[this.#index];
|
|
915
|
+
if (char === "\"") {
|
|
916
|
+
this.#index += 1;
|
|
917
|
+
return JSON.parse(this.text.slice(start, this.#index));
|
|
918
|
+
}
|
|
919
|
+
if (char === "\\") {
|
|
920
|
+
this.#index += 1;
|
|
921
|
+
if (this.#index >= this.text.length)
|
|
922
|
+
break;
|
|
923
|
+
if (this.text[this.#index] === "u") {
|
|
924
|
+
this.#index += 5;
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
this.#index += 1;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
this.#index += 1;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
throw jsonFailure("ARCHIVE_MALFORMED_JSON", `Unterminated JSON string in ${this.entryPath}.`, {
|
|
935
|
+
offset: start,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
#parseNumber() {
|
|
939
|
+
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.text.slice(this.#index));
|
|
940
|
+
if (!match) {
|
|
941
|
+
throw jsonFailure("ARCHIVE_MALFORMED_JSON", `Malformed JSON number in ${this.entryPath}.`, {
|
|
942
|
+
offset: this.#index,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
this.#index += match[0].length;
|
|
946
|
+
}
|
|
947
|
+
#skipWhitespace() {
|
|
948
|
+
while (/[\t\n\r ]/.test(this.text[this.#index] ?? ""))
|
|
949
|
+
this.#index += 1;
|
|
950
|
+
}
|
|
951
|
+
#expect(char) {
|
|
952
|
+
if (this.text[this.#index] !== char) {
|
|
953
|
+
throw jsonFailure("ARCHIVE_MALFORMED_JSON", `Malformed JSON in ${this.entryPath}.`, {
|
|
954
|
+
expected: char,
|
|
955
|
+
offset: this.#index,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
this.#index += 1;
|
|
959
|
+
}
|
|
960
|
+
#peek(char) {
|
|
961
|
+
return this.text[this.#index] === char;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
function checkArchiveLimits(fileCount, totalBytes, fileBytes, entryPath, limits) {
|
|
965
|
+
if (fileCount > limits.maxFiles) {
|
|
966
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
967
|
+
code: "ARCHIVE_FILE_COUNT_LIMIT_EXCEEDED",
|
|
968
|
+
path: entryPath,
|
|
969
|
+
resourceType: "file",
|
|
970
|
+
message: `Archive file count exceeds limit ${limits.maxFiles}.`,
|
|
971
|
+
context: { max_files: limits.maxFiles },
|
|
972
|
+
}));
|
|
973
|
+
}
|
|
974
|
+
if (fileBytes > limits.maxFileBytes || totalBytes > limits.maxExpandedBytes) {
|
|
975
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
976
|
+
code: "ARCHIVE_SIZE_LIMIT_EXCEEDED",
|
|
977
|
+
path: entryPath,
|
|
978
|
+
resourceType: "file",
|
|
979
|
+
message: "Archive expanded size exceeds local Core limits.",
|
|
980
|
+
context: {
|
|
981
|
+
max_file_bytes: limits.maxFileBytes,
|
|
982
|
+
max_expanded_bytes: limits.maxExpandedBytes,
|
|
983
|
+
actual_file_bytes: fileBytes,
|
|
984
|
+
actual_expanded_bytes: totalBytes,
|
|
985
|
+
},
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function validateArchivePath(value, resourceType, resourceId) {
|
|
990
|
+
if (!value ||
|
|
991
|
+
value.startsWith("/") ||
|
|
992
|
+
value.includes("\\") ||
|
|
993
|
+
CONTROL_RE.test(value) ||
|
|
994
|
+
path.posix.normalize(value) !== value ||
|
|
995
|
+
value.split("/").some((segment) => segment === "." || segment === ".." || segment.length === 0)) {
|
|
996
|
+
return diagnostic({
|
|
997
|
+
code: "ARCHIVE_PATH_UNSAFE",
|
|
998
|
+
path: value,
|
|
999
|
+
resourceType,
|
|
1000
|
+
resourceId,
|
|
1001
|
+
message: `Archive path is unsafe: ${value}.`,
|
|
1002
|
+
nextAction: {
|
|
1003
|
+
type: "none",
|
|
1004
|
+
message: "Reject this archive and create a fresh export.",
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
function toArchiveRelativePath(root, absolutePath) {
|
|
1011
|
+
return path.relative(root, absolutePath).split(path.sep).join("/");
|
|
1012
|
+
}
|
|
1013
|
+
function isDescriptor(value) {
|
|
1014
|
+
return (isRecord(value) &&
|
|
1015
|
+
typeof value.mediaType === "string" &&
|
|
1016
|
+
typeof value.digest === "string" &&
|
|
1017
|
+
SHA256_DIGEST_RE.test(value.digest) &&
|
|
1018
|
+
typeof value.size === "number" &&
|
|
1019
|
+
Number.isSafeInteger(value.size) &&
|
|
1020
|
+
value.size >= 0 &&
|
|
1021
|
+
(value.path === undefined || typeof value.path === "string") &&
|
|
1022
|
+
(value.annotations === undefined || isStringRecord(value.annotations)));
|
|
1023
|
+
}
|
|
1024
|
+
function isArchiveSecretRequirement(value) {
|
|
1025
|
+
return (isRecord(value) &&
|
|
1026
|
+
typeof value.name === "string" &&
|
|
1027
|
+
typeof value.required === "boolean" &&
|
|
1028
|
+
(value.targets === undefined || (Array.isArray(value.targets) && value.targets.every((target) => typeof target === "string"))));
|
|
1029
|
+
}
|
|
1030
|
+
function isJsonMediaType(mediaType) {
|
|
1031
|
+
return mediaType.endsWith("+json") || mediaType === "application/json";
|
|
1032
|
+
}
|
|
1033
|
+
function isRecord(value) {
|
|
1034
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1035
|
+
}
|
|
1036
|
+
function isStringRecord(value) {
|
|
1037
|
+
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
|
1038
|
+
}
|
|
1039
|
+
function jsonDepth(value) {
|
|
1040
|
+
if (value === null || typeof value !== "object")
|
|
1041
|
+
return 0;
|
|
1042
|
+
if (Array.isArray(value))
|
|
1043
|
+
return 1 + Math.max(0, ...value.map((entry) => jsonDepth(entry)));
|
|
1044
|
+
return 1 + Math.max(0, ...Object.values(value).map((entry) => jsonDepth(entry)));
|
|
1045
|
+
}
|
|
1046
|
+
function readTarString(bytes, start, length) {
|
|
1047
|
+
const slice = bytes.subarray(start, start + length);
|
|
1048
|
+
const end = slice.indexOf(0);
|
|
1049
|
+
return Buffer.from(end >= 0 ? slice.subarray(0, end) : slice).toString("utf8").trim();
|
|
1050
|
+
}
|
|
1051
|
+
function parseTarSize(value, entryPath) {
|
|
1052
|
+
const trimmed = value.replace(/\0/g, "").trim();
|
|
1053
|
+
if (!/^[0-7]+$/.test(trimmed)) {
|
|
1054
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
1055
|
+
code: "ARCHIVE_MALFORMED_TAR",
|
|
1056
|
+
path: entryPath,
|
|
1057
|
+
resourceType: "tar",
|
|
1058
|
+
message: "Tar entry size is not valid octal.",
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
const size = Number.parseInt(trimmed, 8);
|
|
1062
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
1063
|
+
throw new PortableArchiveReadError(diagnostic({
|
|
1064
|
+
code: "ARCHIVE_SIZE_LIMIT_EXCEEDED",
|
|
1065
|
+
path: entryPath,
|
|
1066
|
+
resourceType: "tar",
|
|
1067
|
+
message: "Tar entry size is not safe.",
|
|
1068
|
+
}));
|
|
1069
|
+
}
|
|
1070
|
+
return size;
|
|
1071
|
+
}
|
|
1072
|
+
function isZeroBlock(bytes) {
|
|
1073
|
+
return bytes.every((byte) => byte === 0);
|
|
1074
|
+
}
|
|
1075
|
+
function normalizeJsonFailure(error) {
|
|
1076
|
+
if (isJsonParseFailure(error))
|
|
1077
|
+
return error;
|
|
1078
|
+
return jsonFailure("ARCHIVE_MALFORMED_JSON", error instanceof Error ? error.message : "Malformed JSON descriptor.");
|
|
1079
|
+
}
|
|
1080
|
+
function isJsonParseFailure(error) {
|
|
1081
|
+
return error instanceof Error && "code" in error && (error.code === "ARCHIVE_DUPLICATE_JSON_KEY" ||
|
|
1082
|
+
error.code === "ARCHIVE_MALFORMED_JSON");
|
|
1083
|
+
}
|
|
1084
|
+
function jsonFailure(code, message, details = {}) {
|
|
1085
|
+
const error = new Error(message);
|
|
1086
|
+
error.code = code;
|
|
1087
|
+
error.details = details;
|
|
1088
|
+
return error;
|
|
1089
|
+
}
|
|
1090
|
+
function emptyVerifyResult(diagnosticEntry, transport) {
|
|
1091
|
+
return {
|
|
1092
|
+
ok: false,
|
|
1093
|
+
archive_version: null,
|
|
1094
|
+
archive_digest: null,
|
|
1095
|
+
transport,
|
|
1096
|
+
file_count: 0,
|
|
1097
|
+
total_bytes: 0,
|
|
1098
|
+
descriptor_count: 0,
|
|
1099
|
+
required_capabilities: [],
|
|
1100
|
+
required_secrets: [],
|
|
1101
|
+
auth_subject_stub_count: 0,
|
|
1102
|
+
export_report: null,
|
|
1103
|
+
portability_report: null,
|
|
1104
|
+
diagnostics: [diagnosticEntry],
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function errorToDiagnostic(error) {
|
|
1108
|
+
if (error instanceof PortableArchiveReadError)
|
|
1109
|
+
return error.diagnostic;
|
|
1110
|
+
return diagnostic({
|
|
1111
|
+
code: "ARCHIVE_ENTRY_TYPE_UNSUPPORTED",
|
|
1112
|
+
resourceType: "archive",
|
|
1113
|
+
message: error instanceof Error ? error.message : "Could not read portable archive.",
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
class PortableArchiveReadError extends Error {
|
|
1117
|
+
diagnostic;
|
|
1118
|
+
constructor(diagnostic) {
|
|
1119
|
+
super(diagnostic.message);
|
|
1120
|
+
this.diagnostic = diagnostic;
|
|
1121
|
+
this.name = "PortableArchiveReadError";
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function diagnostic(input) {
|
|
1125
|
+
return {
|
|
1126
|
+
code: input.code,
|
|
1127
|
+
severity: input.severity ?? "blocking",
|
|
1128
|
+
resource_type: input.resourceType,
|
|
1129
|
+
...(input.resourceId ? { resource_id: input.resourceId } : {}),
|
|
1130
|
+
...(input.path ? { path: input.path } : {}),
|
|
1131
|
+
message: input.message,
|
|
1132
|
+
next_action: input.nextAction ?? { type: "none" },
|
|
1133
|
+
retryable: input.retryable ?? false,
|
|
1134
|
+
...(input.context ? { context: input.context } : {}),
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
//# sourceMappingURL=archive.js.map
|