vana-cli 0.24.5 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/cli/index.d.ts +2 -0
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +91 -12
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/connectors/registry.d.ts +2 -0
  6. package/dist/connectors/registry.d.ts.map +1 -1
  7. package/dist/connectors/registry.js +18 -2
  8. package/dist/connectors/registry.js.map +1 -1
  9. package/dist/core/cli-types.d.ts +44 -0
  10. package/dist/core/cli-types.d.ts.map +1 -1
  11. package/dist/core/cli-types.js +14 -0
  12. package/dist/core/cli-types.js.map +1 -1
  13. package/dist/core/state-store.d.ts +6 -0
  14. package/dist/core/state-store.d.ts.map +1 -1
  15. package/dist/core/state-store.js.map +1 -1
  16. package/dist/pdpp/host.d.ts +66 -0
  17. package/dist/pdpp/host.d.ts.map +1 -0
  18. package/dist/pdpp/host.js +283 -0
  19. package/dist/pdpp/host.js.map +1 -0
  20. package/dist/pdpp/installer.d.ts +17 -0
  21. package/dist/pdpp/installer.d.ts.map +1 -0
  22. package/dist/pdpp/installer.js +117 -0
  23. package/dist/pdpp/installer.js.map +1 -0
  24. package/dist/pdpp/local.d.ts +14 -0
  25. package/dist/pdpp/local.d.ts.map +1 -0
  26. package/dist/pdpp/local.js +76 -0
  27. package/dist/pdpp/local.js.map +1 -0
  28. package/dist/pdpp/pins.d.ts +23 -0
  29. package/dist/pdpp/pins.d.ts.map +1 -0
  30. package/dist/pdpp/pins.js +18 -0
  31. package/dist/pdpp/pins.js.map +1 -0
  32. package/dist/pdpp/profile.d.ts +49 -0
  33. package/dist/pdpp/profile.d.ts.map +1 -0
  34. package/dist/pdpp/profile.js +17 -0
  35. package/dist/pdpp/profile.js.map +1 -0
  36. package/dist/pdpp/protocol.d.ts +79 -0
  37. package/dist/pdpp/protocol.d.ts.map +1 -0
  38. package/dist/pdpp/protocol.js +372 -0
  39. package/dist/pdpp/protocol.js.map +1 -0
  40. package/dist/pdpp/runtime.d.ts +43 -0
  41. package/dist/pdpp/runtime.d.ts.map +1 -0
  42. package/dist/pdpp/runtime.js +387 -0
  43. package/dist/pdpp/runtime.js.map +1 -0
  44. package/dist/pdpp/store.d.ts +43 -0
  45. package/dist/pdpp/store.d.ts.map +1 -0
  46. package/dist/pdpp/store.js +100 -0
  47. package/dist/pdpp/store.js.map +1 -0
  48. package/dist/runtime/core/contracts.d.ts +2 -0
  49. package/dist/runtime/core/contracts.d.ts.map +1 -1
  50. package/dist/runtime/managed-playwright.d.ts +7 -0
  51. package/dist/runtime/managed-playwright.d.ts.map +1 -1
  52. package/dist/runtime/managed-playwright.js +7 -0
  53. package/dist/runtime/managed-playwright.js.map +1 -1
  54. package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
  55. package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
  56. package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
  57. package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
  58. package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
  59. package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
  60. package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
  61. package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
  62. package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
  63. package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
  64. package/dist/vendor/pdpp-connector-manager/package.json +51 -0
  65. package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
  66. package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
  67. package/package.json +7 -2
@@ -0,0 +1,1646 @@
1
+ // Copyright The PDP-Connect Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import {
5
+ existsSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import { execFileSync } from "node:child_process";
16
+ import { tmpdir } from "node:os";
17
+ import {
18
+ dirname,
19
+ join,
20
+ normalize,
21
+ relative,
22
+ resolve as resolvePath,
23
+ sep,
24
+ } from "node:path";
25
+ import { verify as verifySigstoreBundle } from "sigstore";
26
+
27
+ import { readTarGzEntries } from "./tar-stream.mjs";
28
+ import {
29
+ DEFAULT_RETRY_ATTEMPTS,
30
+ DEFAULT_RETRY_BASE_DELAY_MS,
31
+ DEFAULT_RETRY_MAX_DELAY_MS,
32
+ fetchWithRetry,
33
+ } from "./retry.mjs";
34
+
35
+ import {
36
+ DEFAULT_OCI_REGISTRY as DEFAULT_OCI_REGISTRY_NAME,
37
+ OciRegistryError,
38
+ fetchBlob,
39
+ fetchManifestByDigest,
40
+ isValidConnectorKey,
41
+ parseOciReference,
42
+ resolveVersionToDigest,
43
+ sha256Digest,
44
+ } from "./oci-registry.mjs";
45
+ import {
46
+ OCI_CONFIG_MEDIA_TYPE,
47
+ assertConfigMatchesProfile,
48
+ defaultOciCertificateIdentityResolver,
49
+ indexLayersByMediaType,
50
+ toAnchoredIdentityPattern,
51
+ verifyOciSignature,
52
+ } from "./oci-verify.mjs";
53
+
54
+ export {
55
+ DEFAULT_OCI_SIGSTORE_CERTIFICATE_IDENTITY,
56
+ DEFAULT_OCI_SIGSTORE_CERTIFICATE_ISSUER,
57
+ defaultOciCertificateIdentityResolver,
58
+ // One definition, used by both paths: the tarball pin and the OCI pin have
59
+ // the same unanchored-regex hazard and must not drift apart.
60
+ toAnchoredIdentityPattern,
61
+ } from "./oci-verify.mjs";
62
+ export {
63
+ OciRegistryError,
64
+ DEFAULT_OCI_REGISTRY,
65
+ parseConnectorOciReference,
66
+ } from "./oci-registry.mjs";
67
+
68
+ export { fetchCatalog } from "./oci-catalog.mjs";
69
+
70
+ export const DEFAULT_CONNECTOR_INDEX_URL =
71
+ "https://github.com/PDP-Connect/data-connectors/releases/download/connectors-latest/connector-index.json";
72
+ export const DEFAULT_SIGSTORE_CERTIFICATE_ISSUER =
73
+ "https://token.actions.githubusercontent.com";
74
+ export const DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY =
75
+ "https://github.com/PDP-Connect/data-connectors/.github/workflows/publish-connector-release-index.yml@refs/heads/main";
76
+
77
+ export function defaultArtifactCertificateIdentityResolver() {
78
+ return DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY;
79
+ }
80
+
81
+ export function defaultIndexCertificateIdentityResolver() {
82
+ return DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY;
83
+ }
84
+
85
+ export function readJson(path) {
86
+ return JSON.parse(readFileSync(path, "utf8"));
87
+ }
88
+
89
+ export function sha256Buffer(buffer) {
90
+ return `sha256:${createHash("sha256").update(buffer).digest("hex")}`;
91
+ }
92
+
93
+ export function parseVersion(version) {
94
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
95
+ if (!match) {
96
+ throw new Error(`Unsupported version format "${version}"`);
97
+ }
98
+ return {
99
+ major: Number(match[1]),
100
+ minor: Number(match[2]),
101
+ patch: Number(match[3]),
102
+ };
103
+ }
104
+
105
+ export function compareVersions(a, b) {
106
+ const av = typeof a === "string" ? parseVersion(a) : a;
107
+ const bv = typeof b === "string" ? parseVersion(b) : b;
108
+ if (av.major !== bv.major) return av.major - bv.major;
109
+ if (av.minor !== bv.minor) return av.minor - bv.minor;
110
+ return av.patch - bv.patch;
111
+ }
112
+
113
+ function evaluateComparator(version, comparator) {
114
+ if (comparator === "*" || comparator === "") {
115
+ return true;
116
+ }
117
+
118
+ const match = /^(>=|<=|>|<|=|\^|~)?\s*(\d+\.\d+\.\d+)$/.exec(comparator);
119
+ if (!match) {
120
+ throw new Error(`Unsupported comparator "${comparator}"`);
121
+ }
122
+
123
+ const operator = match[1] ?? "=";
124
+ const target = parseVersion(match[2]);
125
+ const cmp = compareVersions(version, target);
126
+
127
+ switch (operator) {
128
+ case "=":
129
+ return cmp === 0;
130
+ case ">":
131
+ return cmp > 0;
132
+ case ">=":
133
+ return cmp >= 0;
134
+ case "<":
135
+ return cmp < 0;
136
+ case "<=":
137
+ return cmp <= 0;
138
+ case "^":
139
+ return (
140
+ cmp >= 0 &&
141
+ compareVersions(version, {
142
+ major: target.major + 1,
143
+ minor: 0,
144
+ patch: 0,
145
+ }) < 0
146
+ );
147
+ case "~":
148
+ return (
149
+ cmp >= 0 &&
150
+ compareVersions(version, {
151
+ major: target.major,
152
+ minor: target.minor + 1,
153
+ patch: 0,
154
+ }) < 0
155
+ );
156
+ default:
157
+ return false;
158
+ }
159
+ }
160
+
161
+ export function satisfies(versionString, range) {
162
+ const version = parseVersion(versionString);
163
+ const normalized = range.trim();
164
+ if (normalized === "" || normalized === "*") {
165
+ return true;
166
+ }
167
+ return normalized
168
+ .split(/\s+/)
169
+ .filter(Boolean)
170
+ .every((token) => evaluateComparator(version, token));
171
+ }
172
+
173
+ export function selectResolvedEntry(entries, constraint, connectorId) {
174
+ const matches = entries.filter((entry) => satisfies(entry.version, constraint));
175
+ if (matches.length === 0) {
176
+ const available = entries.map((entry) => entry.version).join(", ");
177
+ throw new Error(
178
+ `No published version for ${connectorId} satisfies "${constraint}". Available: ${available || "(none)"}`
179
+ );
180
+ }
181
+ return matches.sort((a, b) => compareVersions(b.version, a.version))[0];
182
+ }
183
+
184
+ export function extractAvailableVersions(indexDoc, connectorId) {
185
+ if (!indexDoc.connectors || typeof indexDoc.connectors !== "object") {
186
+ throw new Error("Unsupported connector index shape");
187
+ }
188
+ const entries = indexDoc.connectors[connectorId];
189
+ return Array.isArray(entries) ? entries : [];
190
+ }
191
+
192
+ function findMatchingIndexEntry(indexSource, entry) {
193
+ const entries = extractAvailableVersions(indexSource?.doc ?? {}, entry.connectorId);
194
+ return entries.find((candidate) => candidate.version === entry.version) ?? null;
195
+ }
196
+
197
+ function enrichRemoteEntry(indexSource, entry) {
198
+ if (indexSource?.mode !== "remote") {
199
+ return entry;
200
+ }
201
+
202
+ const matched = findMatchingIndexEntry(indexSource, entry);
203
+ if (!matched) {
204
+ return entry;
205
+ }
206
+
207
+ return {
208
+ ...entry,
209
+ artifactUrl: matched.artifactUrl ?? entry.artifactUrl,
210
+ artifactSignature:
211
+ normalizeSignature(entry.artifactSignature) ??
212
+ normalizeSignature(matched.artifactSignature ?? null),
213
+ };
214
+ }
215
+
216
+ export const ARTIFACT_FETCH_ATTEMPTS = DEFAULT_RETRY_ATTEMPTS;
217
+ export const ARTIFACT_FETCH_BASE_DELAY_MS = DEFAULT_RETRY_BASE_DELAY_MS;
218
+ export const ARTIFACT_FETCH_MAX_DELAY_MS = DEFAULT_RETRY_MAX_DELAY_MS;
219
+
220
+ /**
221
+ * Downloads a release asset, retrying transient failures.
222
+ *
223
+ * GitHub's release-asset CDN intermittently answers a valid, immutable URL with
224
+ * a 5xx that succeeds seconds later. A single attempt turns that blip into a
225
+ * failed `npm ci` for every consumer, so 5xx responses and network errors are
226
+ * retried with exponential backoff.
227
+ *
228
+ * Retries cover only failures a later attempt can plausibly fix. A 4xx is a
229
+ * statement about the request itself — a wrong or withdrawn URL — and repeating
230
+ * it just delays the same error, so it fails immediately. Digest and signature
231
+ * mismatches are likewise never retried: they are verification failures raised
232
+ * by the caller, and bytes that fail a hash check are not made trustworthy by
233
+ * downloading them again.
234
+ */
235
+ export async function fetchBinary(
236
+ url,
237
+ {
238
+ fetchImpl = fetch,
239
+ attempts = ARTIFACT_FETCH_ATTEMPTS,
240
+ baseDelayMs = ARTIFACT_FETCH_BASE_DELAY_MS,
241
+ maxDelayMs = ARTIFACT_FETCH_MAX_DELAY_MS,
242
+ jitter = true,
243
+ random = Math.random,
244
+ sleep,
245
+ now,
246
+ onRetry,
247
+ } = {}
248
+ ) {
249
+ const response = await fetchWithRetry(url, {
250
+ fetchImpl,
251
+ attempts,
252
+ baseDelayMs,
253
+ maxDelayMs,
254
+ jitter,
255
+ random,
256
+ ...(sleep ? { sleep } : {}),
257
+ ...(now ? { now } : {}),
258
+ ...(onRetry ? { onRetry } : {}),
259
+ });
260
+ if (!response.ok) {
261
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
262
+ }
263
+ return Buffer.from(await response.arrayBuffer());
264
+ }
265
+
266
+ function normalizeSignature(signature) {
267
+ if (!signature || typeof signature !== "object") {
268
+ return null;
269
+ }
270
+
271
+ return {
272
+ type: signature.type ?? null,
273
+ bundlePath: signature.bundlePath ?? signature.bundle_path ?? null,
274
+ bundleUrl: signature.bundleUrl ?? signature.bundle_url ?? null,
275
+ };
276
+ }
277
+
278
+ function resolveBundleUrl(subjectUrl, signature) {
279
+ if (signature?.bundleUrl) {
280
+ return signature.bundleUrl;
281
+ }
282
+ if (signature?.bundlePath) {
283
+ return new URL(signature.bundlePath, subjectUrl).toString();
284
+ }
285
+ return `${subjectUrl}.sigstore.json`;
286
+ }
287
+
288
+ async function verifyRemoteSignature({
289
+ payloadBuffer,
290
+ subjectLabel,
291
+ subjectUrl,
292
+ signature,
293
+ allowUnsignedRemote = false,
294
+ certificateIdentityURI = DEFAULT_SIGSTORE_CERTIFICATE_IDENTITY,
295
+ sigstoreVerifier = verifySigstoreBundle,
296
+ fetchImpl = fetch,
297
+ retryOptions = {},
298
+ }) {
299
+ const normalizedSignature = normalizeSignature(signature);
300
+ if (!normalizedSignature) {
301
+ if (allowUnsignedRemote) {
302
+ return false;
303
+ }
304
+ throw new Error(`${subjectLabel} is missing Sigstore bundle metadata`);
305
+ }
306
+ if (
307
+ normalizedSignature.type &&
308
+ normalizedSignature.type !== "sigstoreBundle"
309
+ ) {
310
+ throw new Error(
311
+ `${subjectLabel} uses unsupported signature type "${normalizedSignature.type}"`
312
+ );
313
+ }
314
+
315
+ const bundleUrl = resolveBundleUrl(subjectUrl, normalizedSignature);
316
+ const bundleBuffer = await fetchBinary(bundleUrl, {
317
+ fetchImpl,
318
+ ...retryOptions,
319
+ });
320
+ const bundle = JSON.parse(bundleBuffer.toString("utf8"));
321
+
322
+ try {
323
+ await sigstoreVerifier(bundle, payloadBuffer, {
324
+ certificateIssuer: DEFAULT_SIGSTORE_CERTIFICATE_ISSUER,
325
+ certificateIdentityURI: toAnchoredIdentityPattern(certificateIdentityURI),
326
+ });
327
+ } catch (error) {
328
+ throw new Error(
329
+ `${subjectLabel} signature verification failed: ${error instanceof Error ? error.message : String(error)}`
330
+ );
331
+ }
332
+
333
+ return true;
334
+ }
335
+
336
+ async function resolveArtifactCertificateIdentity({
337
+ artifactCertificateIdentityResolver = defaultArtifactCertificateIdentityResolver,
338
+ artifactUrl,
339
+ entry,
340
+ }) {
341
+ const certificateIdentityURI = await artifactCertificateIdentityResolver({
342
+ artifactUrl,
343
+ entry,
344
+ });
345
+ if (typeof certificateIdentityURI !== "string" || certificateIdentityURI.length === 0) {
346
+ throw new Error(
347
+ `No trusted Sigstore certificate identity configured for connector artifact ${entry.connectorId}@${entry.version}`
348
+ );
349
+ }
350
+ return certificateIdentityURI;
351
+ }
352
+
353
+ async function resolveIndexCertificateIdentity({
354
+ indexCertificateIdentityResolver = defaultIndexCertificateIdentityResolver,
355
+ indexUrl,
356
+ }) {
357
+ const certificateIdentityURI = await indexCertificateIdentityResolver({
358
+ indexUrl,
359
+ });
360
+ if (
361
+ typeof certificateIdentityURI !== "string" ||
362
+ certificateIdentityURI.length === 0
363
+ ) {
364
+ throw new Error(
365
+ `No trusted Sigstore certificate identity configured for connector index ${indexUrl}`
366
+ );
367
+ }
368
+ return certificateIdentityURI;
369
+ }
370
+
371
+ function validateRelativeArtifactPath(relativePath, label = "Artifact path") {
372
+ if (
373
+ typeof relativePath !== "string" ||
374
+ relativePath === "" ||
375
+ relativePath === "." ||
376
+ relativePath.startsWith("/") ||
377
+ /^[A-Za-z]:/.test(relativePath) ||
378
+ relativePath.includes("\\") ||
379
+ relativePath.includes("\0") ||
380
+ relativePath.split("/").includes("..")
381
+ ) {
382
+ throw new Error(`Invalid ${label.toLowerCase()} "${relativePath}"`);
383
+ }
384
+ return relativePath;
385
+ }
386
+
387
+ function ensureInside(baseDir, relativePath) {
388
+ const validPath = validateRelativeArtifactPath(relativePath);
389
+ return join(baseDir, normalize(validPath));
390
+ }
391
+
392
+ export function toPortableArtifactPath(relativePath, pathSeparator = sep) {
393
+ return relativePath.split(pathSeparator).join("/");
394
+ }
395
+
396
+ function walkArtifactFiles(dir, root = dir) {
397
+ if (!existsSync(dir)) {
398
+ return [];
399
+ }
400
+
401
+ const out = [];
402
+ for (const entry of readdirSync(dir)) {
403
+ if (entry.startsWith(".")) continue;
404
+ const full = join(dir, entry);
405
+ const relativePath = toPortableArtifactPath(relative(root, full));
406
+ const st = lstatSync(full);
407
+ if (st.isSymbolicLink()) {
408
+ throw new Error(`Artifact contains unsupported link "${relativePath}"`);
409
+ }
410
+ if (st.isDirectory()) {
411
+ out.push(...walkArtifactFiles(full, root));
412
+ continue;
413
+ }
414
+ if (!st.isFile()) {
415
+ throw new Error(`Artifact contains unsupported entry "${relativePath}"`);
416
+ }
417
+ out.push({
418
+ path: full,
419
+ relativePath,
420
+ });
421
+ }
422
+ return out;
423
+ }
424
+
425
+ function validateArchiveMemberPath(memberPath) {
426
+ const trimmedPath = memberPath.replace(/^\.\//, "").replace(/\/$/, "");
427
+ if (trimmedPath === "") {
428
+ return;
429
+ }
430
+ validateRelativeArtifactPath(trimmedPath, "archive member path");
431
+ }
432
+
433
+ function assertSafeArchive(tarPath) {
434
+ const members = execFileSync("tar", ["-tzf", tarPath], { encoding: "utf8" })
435
+ .split("\n")
436
+ .filter(Boolean);
437
+ for (const memberPath of members) {
438
+ validateArchiveMemberPath(memberPath);
439
+ }
440
+
441
+ const verboseMembers = execFileSync("tar", ["-tvzf", tarPath], { encoding: "utf8" })
442
+ .split("\n")
443
+ .filter(Boolean);
444
+ for (const member of verboseMembers) {
445
+ const type = member[0];
446
+ if (type !== "-" && type !== "d") {
447
+ throw new Error(`Artifact contains unsupported archive entry type "${type}"`);
448
+ }
449
+ }
450
+ }
451
+
452
+ function artifactContract(entry) {
453
+ const artifactKind = entry.artifactKind ?? "legacy";
454
+
455
+ if (artifactKind === "legacy") {
456
+ return {
457
+ manifestPath: "manifest.json",
458
+ entrypointPath: "script.js",
459
+ entrypointChecksum: entry.scriptSha256,
460
+ entrypointLabel: "script",
461
+ provenancePath: null,
462
+ provenanceChecksum: null,
463
+ };
464
+ }
465
+
466
+ if (artifactKind === "pdpp-collection-profile") {
467
+ const manifestPath = validateRelativeArtifactPath(
468
+ entry.manifestPath,
469
+ "PDPP manifest path"
470
+ );
471
+ const entrypointPath = validateRelativeArtifactPath(
472
+ entry.entrypointPath,
473
+ "PDPP entrypoint path"
474
+ );
475
+ const provenancePath = validateRelativeArtifactPath(
476
+ entry.provenancePath,
477
+ "PDPP provenance path"
478
+ );
479
+ for (const [label, checksum] of Object.entries({
480
+ artifact: entry.artifactSha256,
481
+ manifest: entry.manifestSha256,
482
+ entrypoint: entry.entrypointSha256,
483
+ provenance: entry.provenanceSha256,
484
+ })) {
485
+ if (typeof checksum !== "string" || !/^sha256:[0-9a-f]{64}$/.test(checksum)) {
486
+ throw new Error(`${entry.connectorId} PDPP ${label} checksum is required`);
487
+ }
488
+ }
489
+ return {
490
+ manifestPath,
491
+ entrypointPath,
492
+ entrypointChecksum: entry.entrypointSha256,
493
+ entrypointLabel: "entrypoint",
494
+ provenancePath,
495
+ provenanceChecksum: entry.provenanceSha256,
496
+ };
497
+ }
498
+
499
+ throw new Error(`Unsupported artifact kind "${artifactKind}"`);
500
+ }
501
+
502
+ function unpackArtifactBuffer(entry, buffer) {
503
+ const contract = artifactContract(entry);
504
+ const tempRoot = mkdtempSync(join(tmpdir(), "connector-artifact-"));
505
+ const tarPath = join(tempRoot, "artifact.tgz");
506
+ const unpackDir = join(tempRoot, "bundle");
507
+
508
+ try {
509
+ mkdirSync(unpackDir, { recursive: true });
510
+ writeFileSync(tarPath, buffer);
511
+ assertSafeArchive(tarPath);
512
+ execFileSync("tar", ["-xzf", tarPath, "-C", unpackDir]);
513
+
514
+ const files = walkArtifactFiles(unpackDir);
515
+ const manifestFile = files.find((file) => file.relativePath === contract.manifestPath);
516
+ const entrypointFile = files.find((file) => file.relativePath === contract.entrypointPath);
517
+ const provenanceFile = contract.provenancePath
518
+ ? files.find((file) => file.relativePath === contract.provenancePath)
519
+ : null;
520
+ if (!manifestFile || !entrypointFile || (contract.provenancePath && !provenanceFile)) {
521
+ throw new Error(
522
+ `Artifact missing ${!manifestFile ? contract.manifestPath : !entrypointFile ? contract.entrypointPath : contract.provenancePath}`
523
+ );
524
+ }
525
+
526
+ const schemaFiles = [];
527
+ const assetFiles = [];
528
+ let readme = null;
529
+
530
+ for (const file of files) {
531
+ if (
532
+ file.relativePath === contract.manifestPath ||
533
+ file.relativePath === contract.entrypointPath ||
534
+ file.relativePath === contract.provenancePath
535
+ ) {
536
+ continue;
537
+ }
538
+ if (file.relativePath === "README.md") {
539
+ readme = {
540
+ path: file.relativePath,
541
+ buffer: readFileSync(file.path),
542
+ };
543
+ continue;
544
+ }
545
+ if (file.relativePath.startsWith("schemas/")) {
546
+ schemaFiles.push({
547
+ path: file.relativePath,
548
+ buffer: readFileSync(file.path),
549
+ });
550
+ continue;
551
+ }
552
+ assetFiles.push({
553
+ path: file.relativePath,
554
+ buffer: readFileSync(file.path),
555
+ });
556
+ }
557
+
558
+ return {
559
+ manifestBuffer: readFileSync(manifestFile.path),
560
+ entrypointBuffer: readFileSync(entrypointFile.path),
561
+ entrypointPath: contract.entrypointPath,
562
+ entrypointChecksum: contract.entrypointChecksum,
563
+ entrypointLabel: contract.entrypointLabel,
564
+ provenanceBuffer: provenanceFile ? readFileSync(provenanceFile.path) : null,
565
+ provenancePath: contract.provenancePath,
566
+ provenanceChecksum: contract.provenanceChecksum,
567
+ schemaFiles,
568
+ assetFiles,
569
+ readme,
570
+ };
571
+ } finally {
572
+ rmSync(tempRoot, { recursive: true, force: true });
573
+ }
574
+ }
575
+
576
+ function resolveIndexSourcePath(rootDir) {
577
+ const candidates = [
578
+ join(rootDir, "connector-index.json"),
579
+ join(rootDir, "dist", "connector-index.json"),
580
+ ];
581
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
582
+ }
583
+
584
+ export async function loadConnectorIndex({
585
+ fromLocal = null,
586
+ indexUrl = null,
587
+ defaultLocalSource = null,
588
+ defaultIndexUrl = DEFAULT_CONNECTOR_INDEX_URL,
589
+ preferDefaultLocal = false,
590
+ allowUnsignedRemote = false,
591
+ indexCertificateIdentityResolver = defaultIndexCertificateIdentityResolver,
592
+ sigstoreVerifier = undefined,
593
+ fetchImpl = fetch,
594
+ retryOptions = {},
595
+ }) {
596
+ const resolvedLocal = fromLocal
597
+ ? resolvePath(fromLocal)
598
+ : preferDefaultLocal && defaultLocalSource
599
+ ? resolvePath(defaultLocalSource)
600
+ : null;
601
+
602
+ if (resolvedLocal && existsSync(resolvedLocal)) {
603
+ const indexPath = resolveIndexSourcePath(resolvedLocal);
604
+ if (!indexPath) {
605
+ throw new Error(`No connector-index.json found under ${resolvedLocal}`);
606
+ }
607
+ return {
608
+ mode: "local",
609
+ rootDir: resolvedLocal,
610
+ indexUrl: null,
611
+ indexPath,
612
+ doc: readJson(indexPath),
613
+ };
614
+ }
615
+
616
+ const url = indexUrl ?? defaultIndexUrl;
617
+ if (!url) {
618
+ throw new Error("No connector index source configured");
619
+ }
620
+
621
+ const indexBuffer = await fetchBinary(url, { fetchImpl, ...retryOptions });
622
+ const doc = JSON.parse(indexBuffer.toString("utf8"));
623
+ const certificateIdentityURI = await resolveIndexCertificateIdentity({
624
+ indexCertificateIdentityResolver,
625
+ indexUrl: url,
626
+ });
627
+ const signatureVerified = await verifyRemoteSignature({
628
+ payloadBuffer: indexBuffer,
629
+ subjectLabel: "Connector index",
630
+ subjectUrl: url,
631
+ signature: doc.signature,
632
+ allowUnsignedRemote,
633
+ certificateIdentityURI,
634
+ fetchImpl,
635
+ retryOptions,
636
+ ...(sigstoreVerifier ? { sigstoreVerifier } : {}),
637
+ });
638
+
639
+ return {
640
+ mode: "remote",
641
+ rootDir: null,
642
+ indexUrl: url,
643
+ indexPath: null,
644
+ doc,
645
+ signatureVerified,
646
+ };
647
+ }
648
+
649
+ function resolveArtifactLocalPath(rootDir, artifactPath, connectorId) {
650
+ if (!artifactPath) {
651
+ throw new Error(`Local connector index entry for ${connectorId} is missing artifactPath`);
652
+ }
653
+ const resolvedPath = ensureInside(rootDir, artifactPath);
654
+ if (!existsSync(resolvedPath)) {
655
+ throw new Error(`Local artifact not found: ${resolvedPath}`);
656
+ }
657
+ return resolvedPath;
658
+ }
659
+
660
+ function deriveSourceMeta(indexSource, connectors = []) {
661
+ if (indexSource?.mode === "local" && indexSource.rootDir) {
662
+ try {
663
+ const sourceTag = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
664
+ cwd: indexSource.rootDir,
665
+ encoding: "utf8",
666
+ stdio: ["ignore", "pipe", "ignore"],
667
+ }).trim();
668
+ const sourceCommit = execFileSync("git", ["rev-parse", "HEAD"], {
669
+ cwd: indexSource.rootDir,
670
+ encoding: "utf8",
671
+ stdio: ["ignore", "pipe", "ignore"],
672
+ }).trim();
673
+ return { sourceTag, sourceCommit };
674
+ } catch {
675
+ return {
676
+ sourceTag: resolvePath(indexSource.rootDir),
677
+ sourceCommit: "unknown",
678
+ };
679
+ }
680
+ }
681
+
682
+ const sourceTags = [...new Set(connectors.map((entry) => entry.sourceTag).filter(Boolean))];
683
+ const sourceCommits = [...new Set(connectors.map((entry) => entry.sourceCommit).filter(Boolean))];
684
+ return {
685
+ sourceTag: sourceTags.length === 1 ? sourceTags[0] : "mixed",
686
+ sourceCommit: sourceCommits.length === 1 ? sourceCommits[0] : "mixed",
687
+ };
688
+ }
689
+
690
+ /**
691
+ * Read an `oci` block off a lock entry, or null when the entry is a tarball one.
692
+ *
693
+ * `connectorKey` is carried separately from `connectorId` because they are
694
+ * genuinely different strings — `chatgpt-pdpp` is installed at
695
+ * `collection-profiles/chatgpt-pdpp/`, but its artifact lives at
696
+ * `.../connector/chatgpt`. The repository path is a function of the key and the
697
+ * install path is a function of the id, so a consumer that conflates them
698
+ * either fetches the wrong repository or moves every install root.
699
+ */
700
+ function normalizeOciBlock(entry) {
701
+ const oci = entry.oci ?? null;
702
+ if (!oci || typeof oci !== "object") return null;
703
+ return {
704
+ registry: oci.registry ?? DEFAULT_OCI_REGISTRY_NAME,
705
+ repository: oci.repository ?? null,
706
+ digest: oci.digest ?? null,
707
+ configDigest: oci.configDigest ?? oci.config_digest ?? null,
708
+ };
709
+ }
710
+
711
+ /**
712
+ * Which entries in a lock need the signed index, and which do not.
713
+ *
714
+ * The transport is a property of each ENTRY — `fetchEntryArtifact` dispatches on
715
+ * `entry.oci` and never consults `source` for an OCI entry — so whether an index
716
+ * is needed is a question about the lock, not about which flag the operator
717
+ * typed. Deciding it from `--oci` made a lock of digest-pinned OCI entries
718
+ * depend on the old index service anyway, and fail when it was down, while the
719
+ * identical reference passed as `--oci` did not.
720
+ *
721
+ * Exported so the CLI asks this module rather than re-deriving it: a second
722
+ * reading of "is this entry OCI" is a second thing to keep in step with the
723
+ * dispatch, and the two going out of step is exactly the defect.
724
+ *
725
+ * The mixed case is real and stays supported: the twelve legacy `*-playwright`
726
+ * connectors keep the tarball path until they are ported or retired, so a lock
727
+ * naming both transports must load the index for the entries that need it.
728
+ */
729
+ export function lockNeedsIndexSource(lock) {
730
+ return (lock?.connectors ?? []).some((entry) => !normalizeOciBlock(entry));
731
+ }
732
+
733
+ function normalizeLockEntry(entry) {
734
+ const artifactKind = entry.artifactKind ?? entry.artifact_kind ?? "legacy";
735
+ return {
736
+ connectorId: entry.connectorId ?? entry.id,
737
+ connectorKey: entry.connectorKey ?? entry.connector_key ?? null,
738
+ oci: normalizeOciBlock(entry),
739
+ company: entry.company,
740
+ version: entry.version,
741
+ resolvedFrom: entry.resolvedFrom ?? entry.resolved_from ?? entry.version,
742
+ sourceFiles: entry.sourceFiles ?? entry.source_files ?? entry.files,
743
+ artifactUrl: entry.artifactUrl ?? entry.artifact_url ?? null,
744
+ artifactPath: entry.artifactPath ?? entry.artifact_path ?? null,
745
+ artifactSha256: entry.artifactSha256 ?? entry.artifact_sha256 ?? entry.checksums?.artifact,
746
+ artifactSignature: normalizeSignature(
747
+ entry.artifactSignature ?? entry.artifact_signature ?? null
748
+ ),
749
+ manifestSha256:
750
+ entry.manifestSha256 ?? entry.manifest_sha256 ?? entry.checksums?.metadata,
751
+ scriptSha256: entry.scriptSha256 ?? entry.script_sha256 ?? entry.checksums?.script,
752
+ artifactKind,
753
+ manifestPath: entry.manifestPath ?? entry.manifest_path ?? null,
754
+ entrypointPath: entry.entrypointPath ?? entry.entrypoint_path ?? null,
755
+ entrypointSha256:
756
+ entry.entrypointSha256 ?? entry.entrypoint_sha256 ?? entry.checksums?.entrypoint,
757
+ provenancePath: entry.provenancePath ?? entry.provenance_path ?? null,
758
+ provenanceSha256:
759
+ entry.provenanceSha256 ?? entry.provenance_sha256 ?? entry.checksums?.provenance,
760
+ sourceTag: entry.sourceTag ?? entry.source_tag ?? entry.gitRef ?? entry.git_ref ?? null,
761
+ sourceCommit:
762
+ entry.sourceCommit ?? entry.source_commit ?? entry.gitRef ?? entry.git_ref ?? null,
763
+ releaseId: entry.releaseId ?? entry.release_id ?? null,
764
+ publishedAt: entry.publishedAt ?? entry.published_at ?? null,
765
+ name: entry.name ?? null,
766
+ description: entry.description ?? null,
767
+ };
768
+ }
769
+
770
+ /**
771
+ * Read one `.tar.gz` layer, refusing unsafe members and oversized content.
772
+ *
773
+ * Member type and path are checked from the archive's own headers, so a
774
+ * symlink, hardlink or FIFO is refused as it is read rather than after a tree
775
+ * exists (C4.5). Nothing is written to a temp directory at all, so a refusal
776
+ * has nothing to clean up (C6.1).
777
+ *
778
+ * `MAX_BLOB_BYTES` limits compressed input. The tar reader separately limits
779
+ * regular-file bytes, decompressed input consumed, and entry count. It checks
780
+ * effective member sizes before collecting bodies and stops at the archive
781
+ * terminator. These are byte/count limits, not a measured peak-memory bound.
782
+ * The returned regular-file buffers are measured again here.
783
+ */
784
+ export const MAX_LAYER_UNPACKED_BYTES = 64 * 1024 * 1024;
785
+
786
+ async function readLayerArchive(
787
+ buffer,
788
+ label,
789
+ { maxUnpackedBytes = MAX_LAYER_UNPACKED_BYTES } = {}
790
+ ) {
791
+ try {
792
+ const entries = await readTarGzEntries(buffer, {
793
+ maxUnpackedBytes,
794
+ validateMemberPath: validateArchiveMemberPath,
795
+ });
796
+
797
+ let unpacked = 0;
798
+ for (const entry of entries) {
799
+ unpacked += entry.buffer.length;
800
+ if (unpacked > maxUnpackedBytes) {
801
+ throw new Error(
802
+ `archive unpacked to more than the ${maxUnpackedBytes}-byte ceiling`
803
+ );
804
+ }
805
+ }
806
+
807
+ return entries.map((entry) => ({
808
+ path: toPortableArtifactPath(entry.path.replace(/^\.\//, ""), "/"),
809
+ buffer: entry.buffer,
810
+ }));
811
+ } catch (error) {
812
+ throw new OciRegistryError(
813
+ `Refusing ${label}: ${error instanceof Error ? error.message : String(error)}`,
814
+ "unsafe-archive"
815
+ );
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Resolve, verify and unpack one OCI connector artifact.
821
+ *
822
+ * The ORDER here is the contract, and it is the reason this is one function
823
+ * rather than a pipeline a caller assembles: nothing is written, and no layer
824
+ * is even fetched, until the manifest has been proven to be what the lock
825
+ * pinned AND signed by the pinned identity. Every step refuses rather than
826
+ * degrades (C6.1), and because the only writes happen after all of them, a
827
+ * failure at any point leaves the install tree untouched.
828
+ *
829
+ * 1. coordinates are validated, and a non-GHCR registry is refused (C1.1, C1.3)
830
+ * 2. a version is resolved to a digest ONLY when the lock has none (C2.1, C2.3)
831
+ * 3. the manifest is fetched by digest and self-verified (C2.1)
832
+ * 4. the cosign signature is verified against the pinned identity (C3)
833
+ * 5. layers are selected by media type, unknown types refuse (C4.2, C4.3)
834
+ * 6. every blob is verified against its descriptor digest (C4.1)
835
+ * 7. config and profile are cross-checked (C4.4)
836
+ *
837
+ * Returns the same shape `unpackAndVerifyArtifact` returns for a tarball, so
838
+ * everything downstream — install writes, prune, verify — is untouched.
839
+ */
840
+ async function fetchOciArtifact(entry, options = {}) {
841
+ const connectorKey = entry.connectorKey;
842
+ // Checked before any network call: a key that could never have been
843
+ // published is a lock defect, not a registry question (C1.1).
844
+ if (!isValidConnectorKey(connectorKey)) {
845
+ throw new OciRegistryError(
846
+ `Connector ${entry.connectorId} has an invalid connectorKey "${connectorKey}"`,
847
+ "invalid-reference"
848
+ );
849
+ }
850
+
851
+ const reference = parseOciReference({
852
+ registry: entry.oci.registry,
853
+ repository: entry.oci.repository,
854
+ digest: entry.oci.digest,
855
+ version: entry.version,
856
+ ...(options.allowedOciRegistries ? { allowedRegistries: options.allowedOciRegistries } : {}),
857
+ });
858
+ const transport = {
859
+ registry: reference.registry,
860
+ repository: reference.repository,
861
+ scheme: options.ociScheme ?? "https",
862
+ timeoutMs: options.ociTimeoutMs,
863
+ fetchImpl: options.fetchImpl,
864
+ retryOptions: options.retryOptions,
865
+ };
866
+
867
+ // A pinned digest is used as-is, and an unpinned entry is REFUSED rather
868
+ // than resolved — unless the caller is the first-pin entrypoint and says so.
869
+ //
870
+ // Resolving a tag at install time is the defect C1.2 and C2.3 name: a tag is
871
+ // a mutable name, so an entry carrying only a version installs whatever that
872
+ // name points at today, and the lock records nothing that would detect the
873
+ // change. This was previously only a comment; `fetchOciArtifact` serves both
874
+ // the lock-driven path and the CLI's first pin, so the comment described an
875
+ // intent the code did not enforce and every digest-less lock entry silently
876
+ // re-resolved. `allowTagResolution` is set by the first-pin path alone, which
877
+ // is the one operation whose whole purpose is to turn a tag into a digest.
878
+ if (!reference.digest && !options.allowTagResolution) {
879
+ throw new OciRegistryError(
880
+ `OCI entry for ${entry.connectorId} carries no digest; ` +
881
+ `a lock entry must be pinned by digest and is never resolved from its version at install time`,
882
+ "invalid-reference"
883
+ );
884
+ }
885
+ const digest = reference.digest ?? (await resolveVersionToDigest({ ...transport, version: entry.version }));
886
+
887
+ const { manifest } = await fetchManifestByDigest({ ...transport, digest });
888
+
889
+ // Before any layer is written — or fetched (C3.1).
890
+ const trust = await verifyOciSignature({
891
+ ...transport,
892
+ digest,
893
+ certificateIdentityResolver:
894
+ options.ociCertificateIdentityResolver ?? defaultOciCertificateIdentityResolver,
895
+ sigstoreVerifier: options.sigstoreVerifier,
896
+ });
897
+
898
+ const layers = indexLayersByMediaType(manifest, { repository: reference.repository });
899
+
900
+ if (manifest?.config?.mediaType !== OCI_CONFIG_MEDIA_TYPE) {
901
+ throw new OciRegistryError(
902
+ `Refusing ${reference.repository}: unexpected config media type "${manifest?.config?.mediaType}"`,
903
+ "unsupported-layer"
904
+ );
905
+ }
906
+
907
+ // Every one of these is digest-verified inside `fetchBlob` against the
908
+ // descriptor that named it (C4.1).
909
+ const configBytes = await fetchBlob({ ...transport, digest: manifest.config.digest });
910
+ const profileBytes = await fetchBlob({ ...transport, digest: layers.profile.digest });
911
+ const codeBytes = await fetchBlob({ ...transport, digest: layers.code.digest });
912
+ const provenanceBytes = await fetchBlob({ ...transport, digest: layers.provenance.digest });
913
+ const licensesBytes = await fetchBlob({ ...transport, digest: layers.licenses.digest });
914
+ const assetsBytes = layers.assets
915
+ ? await fetchBlob({ ...transport, digest: layers.assets.digest })
916
+ : null;
917
+
918
+ let config;
919
+ let profile;
920
+ try {
921
+ config = JSON.parse(configBytes.toString("utf8"));
922
+ profile = JSON.parse(profileBytes.toString("utf8"));
923
+ } catch (error) {
924
+ throw new OciRegistryError(
925
+ `Refusing ${reference.repository}: config or profile is not JSON (${error.message})`,
926
+ "tampered"
927
+ );
928
+ }
929
+
930
+ assertConfigMatchesProfile({
931
+ config,
932
+ profileBytes,
933
+ profile,
934
+ repository: reference.repository,
935
+ });
936
+
937
+ // The key the lock used to build the repository path must be the key the
938
+ // artifact declares, or the lock fetched from a repository that does not
939
+ // belong to the connector it is installing.
940
+ if (config.connector_key !== connectorKey) {
941
+ throw new OciRegistryError(
942
+ `Refusing ${reference.repository}: artifact declares connector_key ` +
943
+ `"${config.connector_key}" but the lock names "${connectorKey}"`,
944
+ "misidentified"
945
+ );
946
+ }
947
+
948
+ // C4.7: the version installed is the version the lock claims.
949
+ if (entry.version && profile.version !== entry.version) {
950
+ throw new OciRegistryError(
951
+ `${entry.connectorId} version mismatch: lock says ${entry.version} ` +
952
+ `but the artifact profile declares ${profile.version}`,
953
+ "tampered"
954
+ );
955
+ }
956
+
957
+ const codeFiles = await readLayerArchive(codeBytes, `${reference.repository} code layer`);
958
+ const entrypointInArtifact = config.entrypoint;
959
+ let entrypointSegments;
960
+ try {
961
+ const validPath = validateRelativeArtifactPath(entrypointInArtifact, "config.entrypoint");
962
+ entrypointSegments = validPath.split("/");
963
+ if (entrypointSegments.some((segment) => segment === "" || segment === ".")) {
964
+ throw new Error(`Invalid config.entrypoint "${entrypointInArtifact}"`);
965
+ }
966
+ } catch (error) {
967
+ throw new OciRegistryError(
968
+ `Refusing ${reference.repository}: ${error.message}`,
969
+ "tampered"
970
+ );
971
+ }
972
+ const [entrypointLayer, ...memberSegments] = entrypointSegments;
973
+ if (entrypointLayer !== "code" || memberSegments.length === 0) {
974
+ throw new OciRegistryError(
975
+ `Refusing ${reference.repository}: config.entrypoint "${entrypointInArtifact}" ` +
976
+ `must name a member of the code layer`,
977
+ "tampered"
978
+ );
979
+ }
980
+ const codeEntrypointMember = memberSegments.join("/");
981
+ const entrypointFile = codeFiles.find((file) => file.path === codeEntrypointMember);
982
+ // C4.6: the entrypoint the config declares must actually be in the code layer.
983
+ if (!entrypointFile) {
984
+ throw new OciRegistryError(
985
+ `Refusing ${reference.repository}: config.entrypoint "${entrypointInArtifact}" ` +
986
+ `is not present in the code layer`,
987
+ "tampered"
988
+ );
989
+ }
990
+
991
+ const licenseFiles = await readLayerArchive(
992
+ licensesBytes,
993
+ `${reference.repository} licenses layer`
994
+ );
995
+ const assetFiles = assetsBytes
996
+ ? await readLayerArchive(assetsBytes, `${reference.repository} assets layer`)
997
+ : [];
998
+
999
+ // Translated into the layout the installed tree already has (C5.1): the
1000
+ // artifact stores its entrypoint at `code/collection-profile.mjs`, the
1001
+ // installed tree expects it at the lock's `entrypointPath`. Licences and
1002
+ // assets get their own subtrees, and licences are written rather than
1003
+ // dropped because distribution requires shipping them (C5.4).
1004
+ return {
1005
+ manifest: profile,
1006
+ manifestBuffer: profileBytes,
1007
+ entrypointBuffer: entrypointFile.buffer,
1008
+ entrypointPath: entry.entrypointPath,
1009
+ provenanceBuffer: provenanceBytes,
1010
+ provenancePath: entry.provenancePath,
1011
+ artifactKind: entry.artifactKind,
1012
+ schemaFiles: [],
1013
+ assetFiles: [
1014
+ ...licenseFiles.map((file) => ({ path: `licenses/${file.path}`, buffer: file.buffer })),
1015
+ ...assetFiles.map((file) => ({ path: `assets/${file.path}`, buffer: file.buffer })),
1016
+ ],
1017
+ readme: null,
1018
+ oci: {
1019
+ registry: reference.registry,
1020
+ repository: reference.repository,
1021
+ digest,
1022
+ configDigest: manifest.config.digest,
1023
+ certificateIdentityURI: trust.certificateIdentityURI,
1024
+ },
1025
+ checksums: {
1026
+ artifact: digest,
1027
+ manifest: sha256Digest(profileBytes),
1028
+ entrypoint: sha256Digest(entrypointFile.buffer),
1029
+ provenance: sha256Digest(provenanceBytes),
1030
+ },
1031
+ };
1032
+ }
1033
+
1034
+ async function fetchArtifactForEntry(indexSource, entry, options = {}) {
1035
+ const resolvedEntry = enrichRemoteEntry(indexSource, entry);
1036
+
1037
+ if (indexSource?.mode === "local") {
1038
+ const artifactPath = resolveArtifactLocalPath(
1039
+ indexSource.rootDir,
1040
+ resolvedEntry.artifactPath,
1041
+ resolvedEntry.connectorId
1042
+ );
1043
+ return readFileSync(artifactPath);
1044
+ }
1045
+
1046
+ if (!resolvedEntry.artifactUrl) {
1047
+ throw new Error(`Connector ${resolvedEntry.connectorId} is missing artifactUrl`);
1048
+ }
1049
+ const artifactBuffer = await fetchBinary(resolvedEntry.artifactUrl, {
1050
+ fetchImpl: options.fetchImpl,
1051
+ ...options.retryOptions,
1052
+ });
1053
+ const certificateIdentityURI = await resolveArtifactCertificateIdentity({
1054
+ artifactCertificateIdentityResolver: options.artifactCertificateIdentityResolver,
1055
+ artifactUrl: resolvedEntry.artifactUrl,
1056
+ entry: resolvedEntry,
1057
+ });
1058
+ await verifyRemoteSignature({
1059
+ payloadBuffer: artifactBuffer,
1060
+ subjectLabel: `Connector artifact ${resolvedEntry.connectorId}@${resolvedEntry.version}`,
1061
+ subjectUrl: resolvedEntry.artifactUrl,
1062
+ signature: resolvedEntry.artifactSignature,
1063
+ certificateIdentityURI,
1064
+ sigstoreVerifier: options.sigstoreVerifier,
1065
+ fetchImpl: options.fetchImpl,
1066
+ retryOptions: options.retryOptions,
1067
+ });
1068
+ return artifactBuffer;
1069
+ }
1070
+
1071
+ function unpackAndVerifyArtifact(entry, artifactBuffer) {
1072
+ const artifactChecksum = sha256Buffer(artifactBuffer);
1073
+ if (entry.artifactSha256 && entry.artifactSha256 !== artifactChecksum) {
1074
+ throw new Error(
1075
+ `${entry.connectorId} artifact checksum mismatch: expected ${entry.artifactSha256}, got ${artifactChecksum}`
1076
+ );
1077
+ }
1078
+
1079
+ const unpacked = unpackArtifactBuffer(entry, artifactBuffer);
1080
+ const manifest = JSON.parse(unpacked.manifestBuffer.toString("utf8"));
1081
+ const manifestChecksum = sha256Buffer(unpacked.manifestBuffer);
1082
+ const entrypointChecksum = sha256Buffer(unpacked.entrypointBuffer);
1083
+ const provenanceChecksum = unpacked.provenanceBuffer
1084
+ ? sha256Buffer(unpacked.provenanceBuffer)
1085
+ : null;
1086
+
1087
+ if (entry.manifestSha256 && entry.manifestSha256 !== manifestChecksum) {
1088
+ throw new Error(
1089
+ `${entry.connectorId} manifest checksum mismatch: expected ${entry.manifestSha256}, got ${manifestChecksum}`
1090
+ );
1091
+ }
1092
+ if (unpacked.entrypointChecksum && unpacked.entrypointChecksum !== entrypointChecksum) {
1093
+ throw new Error(
1094
+ `${entry.connectorId} ${unpacked.entrypointLabel} checksum mismatch: expected ${unpacked.entrypointChecksum}, got ${entrypointChecksum}`
1095
+ );
1096
+ }
1097
+ if (unpacked.provenanceChecksum && unpacked.provenanceChecksum !== provenanceChecksum) {
1098
+ throw new Error(
1099
+ `${entry.connectorId} provenance checksum mismatch: expected ${unpacked.provenanceChecksum}, got ${provenanceChecksum}`
1100
+ );
1101
+ }
1102
+
1103
+ if (entry.version && manifest.version !== entry.version) {
1104
+ throw new Error(
1105
+ `${entry.connectorId} version mismatch: index says ${entry.version} but artifact manifest declares ${manifest.version}`
1106
+ );
1107
+ }
1108
+
1109
+ if (entry.artifactKind === "legacy" && manifest.connector_id && manifest.connector_id !== entry.connectorId) {
1110
+ throw new Error(
1111
+ `${entry.connectorId} artifact manifest declares connector_id ${manifest.connector_id}`
1112
+ );
1113
+ }
1114
+
1115
+ return {
1116
+ manifest,
1117
+ manifestBuffer: unpacked.manifestBuffer,
1118
+ entrypointBuffer: unpacked.entrypointBuffer,
1119
+ entrypointPath: unpacked.entrypointPath,
1120
+ provenanceBuffer: unpacked.provenanceBuffer,
1121
+ provenancePath: unpacked.provenancePath,
1122
+ artifactKind: entry.artifactKind,
1123
+ ...(entry.artifactKind === "legacy" ? { scriptBuffer: unpacked.entrypointBuffer } : {}),
1124
+ schemaFiles: unpacked.schemaFiles,
1125
+ assetFiles: unpacked.assetFiles,
1126
+ readme: unpacked.readme,
1127
+ checksums: {
1128
+ artifact: artifactChecksum,
1129
+ manifest: manifestChecksum,
1130
+ entrypoint: entrypointChecksum,
1131
+ ...(provenanceChecksum ? { provenance: provenanceChecksum } : {}),
1132
+ ...(entry.artifactKind === "legacy" ? { script: entrypointChecksum } : {}),
1133
+ },
1134
+ };
1135
+ }
1136
+
1137
+ function normalizeFetchedArtifact(entry, artifact) {
1138
+ return {
1139
+ connectorId: entry.connectorId,
1140
+ company: entry.company,
1141
+ version: entry.version,
1142
+ resolvedFrom: entry.resolvedFrom,
1143
+ entry,
1144
+ ...artifact,
1145
+ };
1146
+ }
1147
+
1148
+ function projectFetchedArtifact(entry, artifact) {
1149
+ if (entry.artifactKind === "legacy") {
1150
+ return {
1151
+ manifest: artifact.manifest,
1152
+ manifestBuffer: artifact.manifestBuffer,
1153
+ scriptBuffer: artifact.entrypointBuffer,
1154
+ schemaFiles: artifact.schemaFiles,
1155
+ assetFiles: artifact.assetFiles,
1156
+ readme: artifact.readme,
1157
+ checksums: {
1158
+ artifact: artifact.checksums.artifact,
1159
+ manifest: artifact.checksums.manifest,
1160
+ script: artifact.checksums.script,
1161
+ },
1162
+ };
1163
+ }
1164
+ return artifact;
1165
+ }
1166
+
1167
+ function metadataDirFromSourceFiles(entry) {
1168
+ const sourceFiles = entry.sourceFiles;
1169
+ if (!sourceFiles?.metadata || !sourceFiles?.script) {
1170
+ throw new Error(`Connector ${entry.connectorId} is missing sourceFiles metadata/script`);
1171
+ }
1172
+ return dirname(sourceFiles.metadata);
1173
+ }
1174
+
1175
+ function buildSnapshotWrites(installRoot, resolved) {
1176
+ const writes = [
1177
+ {
1178
+ relativePath: `manifests/${resolved.connectorId}.json`,
1179
+ buffer: resolved.manifestBuffer,
1180
+ },
1181
+ {
1182
+ relativePath: `scripts/${resolved.connectorId}.js`,
1183
+ buffer: resolved.entrypointBuffer,
1184
+ },
1185
+ ];
1186
+
1187
+ for (const schemaFile of resolved.schemaFiles) {
1188
+ const fileName = schemaFile.path.split("/").at(-1);
1189
+ if (!fileName) continue;
1190
+ const relativePath = `schemas/${fileName}`;
1191
+ writes.push({
1192
+ relativePath,
1193
+ buffer: schemaFile.buffer,
1194
+ });
1195
+ }
1196
+
1197
+ for (const assetFile of resolved.assetFiles) {
1198
+ writes.push({
1199
+ relativePath: `assets/${resolved.connectorId}/${assetFile.path}`,
1200
+ buffer: assetFile.buffer,
1201
+ });
1202
+ }
1203
+
1204
+ return writes.map((write) => ({
1205
+ ...write,
1206
+ absolutePath: join(installRoot, write.relativePath),
1207
+ }));
1208
+ }
1209
+
1210
+ function buildSourceWrites(installRoot, resolved) {
1211
+ const metadataDir = metadataDirFromSourceFiles(resolved.entry);
1212
+ const writes = [
1213
+ {
1214
+ relativePath: resolved.entry.sourceFiles.metadata,
1215
+ buffer: resolved.manifestBuffer,
1216
+ },
1217
+ {
1218
+ relativePath: resolved.entry.sourceFiles.script,
1219
+ buffer: resolved.entrypointBuffer,
1220
+ },
1221
+ ];
1222
+
1223
+ for (const schemaFile of resolved.schemaFiles) {
1224
+ const fileName = schemaFile.path.split("/").at(-1);
1225
+ if (!fileName || fileName === "manifest.schema.json") continue;
1226
+ writes.push({
1227
+ relativePath: join(metadataDir, "schemas", fileName),
1228
+ buffer: schemaFile.buffer,
1229
+ });
1230
+ }
1231
+
1232
+ for (const assetFile of resolved.assetFiles) {
1233
+ writes.push({
1234
+ relativePath: join(metadataDir, assetFile.path),
1235
+ buffer: assetFile.buffer,
1236
+ });
1237
+ }
1238
+
1239
+ if (resolved.readme) {
1240
+ writes.push({
1241
+ relativePath: join(metadataDir, resolved.readme.path),
1242
+ buffer: resolved.readme.buffer,
1243
+ });
1244
+ }
1245
+
1246
+ return writes.map((write) => ({
1247
+ ...write,
1248
+ absolutePath: join(installRoot, write.relativePath),
1249
+ }));
1250
+ }
1251
+
1252
+ function buildPdppCollectionProfileWrites(installRoot, resolved) {
1253
+ const artifactRoot = `collection-profiles/${resolved.connectorId}`;
1254
+ const writes = [
1255
+ {
1256
+ relativePath: `${artifactRoot}/${resolved.entry.manifestPath}`,
1257
+ buffer: resolved.manifestBuffer,
1258
+ },
1259
+ {
1260
+ relativePath: `${artifactRoot}/${resolved.entry.entrypointPath}`,
1261
+ buffer: resolved.entrypointBuffer,
1262
+ },
1263
+ {
1264
+ relativePath: `${artifactRoot}/${resolved.entry.provenancePath}`,
1265
+ buffer: resolved.provenanceBuffer,
1266
+ },
1267
+ // Licences and brand assets, which the publisher ships unconditionally
1268
+ // because distributing the code requires distributing them (C5.4), so they
1269
+ // are written rather than dropped on the floor.
1270
+ //
1271
+ // Gated on `resolved.oci` rather than on `assetFiles` being non-empty,
1272
+ // and the difference is not cosmetic. A TARBALL collection-profile
1273
+ // artifact also populates `assetFiles` — `unpackArtifactBuffer` puts every
1274
+ // file that is not the manifest, entrypoint, provenance, a schema or the
1275
+ // README there. Spreading it unconditionally therefore starts installing
1276
+ // stray files from tarball artifacts that were previously ignored, which
1277
+ // is a behaviour change to the path this PR is supposed to leave alone.
1278
+ // Measured, not assumed: an unconditional spread wrote a fourth file for a
1279
+ // tarball artifact carrying one extra member. Today's two published
1280
+ // collection profiles happen to carry no such member, so no existing test
1281
+ // fails either way — which is exactly why the gate is on the transport and
1282
+ // not on whether the list happens to be empty.
1283
+ ...(resolved.oci
1284
+ ? resolved.assetFiles.map((file) => ({
1285
+ relativePath: `${artifactRoot}/${validateRelativeArtifactPath(file.path, "artifact asset path")}`,
1286
+ buffer: file.buffer,
1287
+ }))
1288
+ : []),
1289
+ ];
1290
+
1291
+ return writes.map((write) => ({
1292
+ ...write,
1293
+ absolutePath: join(installRoot, write.relativePath),
1294
+ }));
1295
+ }
1296
+
1297
+ function buildInstallWrites(layout, installRoot, resolved) {
1298
+ if (resolved.artifactKind === "pdpp-collection-profile") {
1299
+ if (layout === "snapshot" || layout === "source") {
1300
+ return buildPdppCollectionProfileWrites(installRoot, resolved);
1301
+ }
1302
+ throw new Error(`Unsupported install layout "${layout}"`);
1303
+ }
1304
+ if (layout === "snapshot") {
1305
+ return buildSnapshotWrites(installRoot, resolved);
1306
+ }
1307
+ if (layout === "source") {
1308
+ return buildSourceWrites(installRoot, resolved);
1309
+ }
1310
+ throw new Error(`Unsupported install layout "${layout}"`);
1311
+ }
1312
+
1313
+ async function fetchLockArtifacts({
1314
+ lock,
1315
+ source,
1316
+ artifactCertificateIdentityResolver,
1317
+ ...options
1318
+ }) {
1319
+ const normalizedEntries = (lock.connectors ?? []).map(normalizeLockEntry);
1320
+ const resolved = [];
1321
+
1322
+ for (const entry of normalizedEntries) {
1323
+ const artifact = await fetchEntryArtifact(source, entry, {
1324
+ artifactCertificateIdentityResolver,
1325
+ ...options,
1326
+ });
1327
+ resolved.push(normalizeFetchedArtifact(entry, artifact));
1328
+ }
1329
+
1330
+ return resolved;
1331
+ }
1332
+
1333
+ /**
1334
+ * The transport dispatch: an entry carrying an `oci` block is pulled from a
1335
+ * registry, everything else keeps the tarball path byte for byte.
1336
+ *
1337
+ * The lock is the switch. There is no feature flag and no environment
1338
+ * variable, because the presence of `oci` on an entry already says which
1339
+ * transport that entry uses, and a flag would be a second place for the answer
1340
+ * to live — one that CI cannot diff.
1341
+ */
1342
+ async function fetchEntryArtifact(source, entry, options = {}) {
1343
+ if (entry.oci) {
1344
+ return fetchOciArtifact(entry, options);
1345
+ }
1346
+ const artifactBuffer = await fetchArtifactForEntry(source, entry, options);
1347
+ return unpackAndVerifyArtifact(entry, artifactBuffer);
1348
+ }
1349
+
1350
+ function expectedWritesForLock({ installRoot, layout, resolved }) {
1351
+ return resolved.flatMap((entry) => buildInstallWrites(layout, installRoot, entry));
1352
+ }
1353
+
1354
+ function ensureParentDir(path) {
1355
+ mkdirSync(dirname(path), { recursive: true });
1356
+ }
1357
+
1358
+ function removeUnexpectedEntries(installRoot, expectedPaths, preserveTopLevel = []) {
1359
+ const expected = new Set(expectedPaths);
1360
+ const preserve = new Set(preserveTopLevel);
1361
+
1362
+ function pruneDirectory(dir, relativeDir = "") {
1363
+ for (const entry of readdirSync(dir)) {
1364
+ if (entry.startsWith(".")) {
1365
+ continue;
1366
+ }
1367
+ const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry;
1368
+ const topLevel = relativePath.split("/")[0];
1369
+ if (preserve.has(topLevel)) {
1370
+ continue;
1371
+ }
1372
+
1373
+ const path = join(dir, entry);
1374
+ const stat = lstatSync(path);
1375
+ if (stat.isDirectory()) {
1376
+ pruneDirectory(path, relativePath);
1377
+ if (readdirSync(path).length === 0) {
1378
+ rmSync(path, { recursive: true, force: true });
1379
+ }
1380
+ continue;
1381
+ }
1382
+ if (!expected.has(relativePath)) {
1383
+ rmSync(path, { force: true });
1384
+ }
1385
+ }
1386
+ }
1387
+
1388
+ if (existsSync(installRoot)) {
1389
+ pruneDirectory(installRoot);
1390
+ }
1391
+ }
1392
+
1393
+ export async function fetchResolvedArtifact(indexSource, entry, options = {}) {
1394
+ const normalizedEntry = normalizeLockEntry(entry);
1395
+ return projectFetchedArtifact(
1396
+ normalizedEntry,
1397
+ await fetchEntryArtifact(indexSource, normalizedEntry, options)
1398
+ );
1399
+ }
1400
+
1401
+ export async function resolveConnectorArtifacts({
1402
+ dependencies,
1403
+ requestedConnectorIds,
1404
+ source,
1405
+ artifactCertificateIdentityResolver,
1406
+ }) {
1407
+ const connectorIds = requestedConnectorIds ?? Object.keys(dependencies.connectors);
1408
+ const resolved = [];
1409
+
1410
+ for (const connectorId of connectorIds) {
1411
+ const constraint = dependencies.connectors[connectorId];
1412
+ if (!constraint) {
1413
+ throw new Error(`Missing version constraint for ${connectorId}`);
1414
+ }
1415
+ const availableEntries = extractAvailableVersions(source.doc, connectorId);
1416
+ const selected = selectResolvedEntry(availableEntries, constraint, connectorId);
1417
+ const artifact = await fetchResolvedArtifact(source, selected, {
1418
+ artifactCertificateIdentityResolver,
1419
+ });
1420
+ resolved.push({
1421
+ connectorId,
1422
+ constraint,
1423
+ entry: selected,
1424
+ ...artifact,
1425
+ });
1426
+ }
1427
+
1428
+ return {
1429
+ source,
1430
+ resolved,
1431
+ };
1432
+ }
1433
+
1434
+ export async function generateLock({
1435
+ dependencies,
1436
+ source,
1437
+ dependencyFile = null,
1438
+ lockVersion = "1.0",
1439
+ generatedAt = new Date().toISOString(),
1440
+ requestedConnectorIds,
1441
+ artifactCertificateIdentityResolver,
1442
+ }) {
1443
+ const resolution = await resolveConnectorArtifacts({
1444
+ dependencies,
1445
+ source,
1446
+ requestedConnectorIds,
1447
+ artifactCertificateIdentityResolver,
1448
+ });
1449
+ const sourceMeta = deriveSourceMeta(
1450
+ source,
1451
+ resolution.resolved.map((entry) => entry.entry)
1452
+ );
1453
+
1454
+ return {
1455
+ lockVersion,
1456
+ dependencyFile,
1457
+ generatedAt,
1458
+ sourceRepo:
1459
+ source.doc.sourceRepo ??
1460
+ dependencies.source_repo ??
1461
+ "https://github.com/PDP-Connect/data-connectors",
1462
+ sourceTag: sourceMeta.sourceTag,
1463
+ sourceCommit: sourceMeta.sourceCommit,
1464
+ index: {
1465
+ mode: source.mode,
1466
+ path: source.indexPath,
1467
+ url: source.indexUrl,
1468
+ version: source.doc.indexVersion ?? "unknown",
1469
+ signatureVerified: source.signatureVerified ?? false,
1470
+ },
1471
+ dependencies: dependencies.connectors,
1472
+ connectors: resolution.resolved
1473
+ .map((resolved) => ({
1474
+ connectorId: resolved.connectorId,
1475
+ company: resolved.entry.company,
1476
+ version: resolved.entry.version,
1477
+ resolvedFrom: resolved.constraint,
1478
+ sourceFiles: resolved.entry.sourceFiles,
1479
+ artifactUrl: resolved.entry.artifactUrl ?? null,
1480
+ artifactPath: resolved.entry.artifactPath ?? null,
1481
+ artifactSha256: resolved.checksums.artifact,
1482
+ artifactSignature: resolved.entry.artifactSignature ?? null,
1483
+ manifestSha256: resolved.checksums.manifest,
1484
+ ...(resolved.entry.artifactKind === "pdpp-collection-profile"
1485
+ ? {
1486
+ artifactKind: resolved.entry.artifactKind,
1487
+ manifestPath: resolved.entry.manifestPath,
1488
+ entrypointPath: resolved.entry.entrypointPath,
1489
+ entrypointSha256: resolved.checksums.entrypoint,
1490
+ provenancePath: resolved.entry.provenancePath,
1491
+ provenanceSha256: resolved.checksums.provenance,
1492
+ }
1493
+ : { scriptSha256: resolved.checksums.script }),
1494
+ sourceTag: resolved.entry.sourceTag ?? resolved.entry.gitRef ?? sourceMeta.sourceTag,
1495
+ sourceCommit:
1496
+ resolved.entry.sourceCommit ?? resolved.entry.gitRef ?? sourceMeta.sourceCommit,
1497
+ releaseId: resolved.entry.releaseId ?? null,
1498
+ publishedAt: resolved.entry.publishedAt ?? null,
1499
+ name: resolved.entry.name ?? resolved.manifest.name,
1500
+ description: resolved.entry.description ?? resolved.manifest.description,
1501
+ }))
1502
+ .sort((a, b) => a.connectorId.localeCompare(b.connectorId)),
1503
+ };
1504
+ }
1505
+
1506
+ export async function checkForUpdates({ lock, indexDoc }) {
1507
+ const updates = [];
1508
+
1509
+ for (const rawLockEntry of lock.connectors ?? []) {
1510
+ const lockEntry = normalizeLockEntry(rawLockEntry);
1511
+ const availableEntries = extractAvailableVersions(indexDoc, lockEntry.connectorId);
1512
+ if (availableEntries.length === 0) {
1513
+ updates.push({
1514
+ connectorId: lockEntry.connectorId,
1515
+ status: "missing_from_index",
1516
+ currentVersion: lockEntry.version,
1517
+ latestVersion: null,
1518
+ });
1519
+ continue;
1520
+ }
1521
+
1522
+ const latest = availableEntries.sort((a, b) => compareVersions(b.version, a.version))[0];
1523
+ if (compareVersions(latest.version, lockEntry.version) > 0) {
1524
+ updates.push({
1525
+ connectorId: lockEntry.connectorId,
1526
+ status: "update_available",
1527
+ currentVersion: lockEntry.version,
1528
+ latestVersion: latest.version,
1529
+ artifactSha256: latest.artifactSha256,
1530
+ artifactSignature: normalizeSignature(latest.artifactSignature ?? null),
1531
+ });
1532
+ }
1533
+ }
1534
+
1535
+ return {
1536
+ hasUpdates: updates.length > 0,
1537
+ updates,
1538
+ };
1539
+ }
1540
+
1541
+ export function pruneInstalled({
1542
+ installRoot,
1543
+ expectedPaths,
1544
+ preserveTopLevel = [],
1545
+ }) {
1546
+ removeUnexpectedEntries(installRoot, expectedPaths, preserveTopLevel);
1547
+ return {
1548
+ installRoot,
1549
+ expectedCount: expectedPaths.length,
1550
+ };
1551
+ }
1552
+
1553
+ export async function installFromLock({
1554
+ lock,
1555
+ source,
1556
+ installRoot,
1557
+ layout,
1558
+ prune = false,
1559
+ preserveTopLevel = [],
1560
+ artifactCertificateIdentityResolver,
1561
+ ...options
1562
+ }) {
1563
+ const resolved = await fetchLockArtifacts({
1564
+ lock,
1565
+ source,
1566
+ artifactCertificateIdentityResolver,
1567
+ ...options,
1568
+ });
1569
+ const writes = expectedWritesForLock({ installRoot, layout, resolved });
1570
+ const expectedPaths = writes.map((write) => write.relativePath);
1571
+
1572
+ for (const write of writes) {
1573
+ ensureParentDir(write.absolutePath);
1574
+ writeFileSync(write.absolutePath, write.buffer);
1575
+ }
1576
+
1577
+ if (prune) {
1578
+ pruneInstalled({
1579
+ installRoot,
1580
+ expectedPaths,
1581
+ preserveTopLevel,
1582
+ });
1583
+ }
1584
+
1585
+ return {
1586
+ installRoot,
1587
+ layout,
1588
+ connectorCount: resolved.length,
1589
+ filesWritten: writes.length,
1590
+ expectedPaths,
1591
+ // What was actually installed, by digest. A first pin resolves a tag, and
1592
+ // the digest it resolved to is the thing the caller needs in order to write
1593
+ // a lock entry that will not re-resolve (C1.2); printing it is how a
1594
+ // one-off `--oci ref:version` pull becomes a pinned one.
1595
+ pinned: resolved
1596
+ .filter((artifact) => artifact.oci)
1597
+ .map((artifact) => ({
1598
+ connectorId: artifact.connectorId,
1599
+ version: artifact.version,
1600
+ registry: artifact.oci.registry,
1601
+ repository: artifact.oci.repository,
1602
+ digest: artifact.oci.digest,
1603
+ })),
1604
+ };
1605
+ }
1606
+
1607
+ export async function verifyInstalled({
1608
+ lock,
1609
+ source,
1610
+ installRoot,
1611
+ layout,
1612
+ artifactCertificateIdentityResolver,
1613
+ ...options
1614
+ }) {
1615
+ const resolved = await fetchLockArtifacts({
1616
+ lock,
1617
+ source,
1618
+ artifactCertificateIdentityResolver,
1619
+ ...options,
1620
+ });
1621
+ const writes = expectedWritesForLock({ installRoot, layout, resolved });
1622
+ const missing = [];
1623
+ const mismatched = [];
1624
+
1625
+ for (const write of writes) {
1626
+ if (!existsSync(write.absolutePath)) {
1627
+ missing.push(write.relativePath);
1628
+ continue;
1629
+ }
1630
+
1631
+ const currentChecksum = sha256Buffer(readFileSync(write.absolutePath));
1632
+ const expectedChecksum = sha256Buffer(write.buffer);
1633
+ if (currentChecksum !== expectedChecksum) {
1634
+ mismatched.push(write.relativePath);
1635
+ }
1636
+ }
1637
+
1638
+ return {
1639
+ ok: missing.length === 0 && mismatched.length === 0,
1640
+ installRoot,
1641
+ layout,
1642
+ expectedCount: writes.length,
1643
+ missing,
1644
+ mismatched,
1645
+ };
1646
+ }