@xemahq/xema 0.38.0 → 0.40.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 (63) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/distribution-assets.manifest.json +2 -70
  3. package/dist/commands/biome-dev.js +2 -3
  4. package/dist/commands/biome-dev.js.map +1 -1
  5. package/dist/commands/dev.js +2 -3
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/doctor.d.ts.map +1 -1
  8. package/dist/commands/doctor.js +3 -2
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/init.js +21 -9
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/installation.d.ts.map +1 -1
  13. package/dist/commands/installation.js +7 -5
  14. package/dist/commands/installation.js.map +1 -1
  15. package/dist/commands/license.d.ts.map +1 -1
  16. package/dist/commands/license.js +2 -1
  17. package/dist/commands/license.js.map +1 -1
  18. package/dist/commands/serve.js +1 -1
  19. package/dist/commands/serve.js.map +1 -1
  20. package/dist/commands/up.d.ts.map +1 -1
  21. package/dist/commands/up.js +3 -5
  22. package/dist/commands/up.js.map +1 -1
  23. package/dist/edition/verify-image-evidence.bundle.mjs +14 -2
  24. package/dist/edition/verify-image-evidence.bundle.provenance.json +41 -41
  25. package/dist/runtime/config/init-presets.d.ts.map +1 -1
  26. package/dist/runtime/config/init-presets.js +3 -5
  27. package/dist/runtime/config/init-presets.js.map +1 -1
  28. package/dist/runtime/config/loader.d.ts +2 -2
  29. package/dist/runtime/config/loader.d.ts.map +1 -1
  30. package/dist/runtime/config/loader.js +1 -1
  31. package/dist/runtime/config/loader.js.map +1 -1
  32. package/dist/runtime/config/schema.d.ts +149 -1
  33. package/dist/runtime/config/schema.d.ts.map +1 -1
  34. package/dist/runtime/config/schema.js +17 -4
  35. package/dist/runtime/config/schema.js.map +1 -1
  36. package/dist/runtime/installation/setup-profile.d.ts +1 -1
  37. package/dist/runtime/installation/setup-profile.d.ts.map +1 -1
  38. package/dist/runtime/installation/setup-profile.js +1 -1
  39. package/dist/runtime/installation/setup-profile.js.map +1 -1
  40. package/edition/biome-content.mjs +35 -6
  41. package/edition/build-platform-service-index.mjs +1 -1
  42. package/edition/develop-edition-resolve.mjs +58 -20
  43. package/edition/develop-image-probe.mjs +117 -0
  44. package/edition/develop-seal.mjs +4 -12
  45. package/edition/dispatched-runtime-components.mjs +1 -1
  46. package/edition/dist-check.mjs +3 -2
  47. package/edition/lib.mjs +1 -1
  48. package/edition/map-with-concurrency.mjs +19 -0
  49. package/edition/oras-process.mjs +35 -0
  50. package/edition/oras.mjs +123 -94
  51. package/edition/resolve-chart-set.mjs +12 -8
  52. package/edition/resolve-images.mjs +119 -34
  53. package/edition/verify-image-platforms.mjs +28 -26
  54. package/edition/workspace-sections.mjs +22 -1
  55. package/package.json +2 -2
  56. package/dist/assets/cloud/distribution.lock.json +0 -6447
  57. package/dist/assets/cloud/images.lock.json +0 -1831
  58. package/dist/assets/minimal/distribution.lock.json +0 -3297
  59. package/dist/assets/minimal/images.lock.json +0 -1189
  60. package/dist/assets/oss/distribution.lock.json +0 -6266
  61. package/dist/assets/oss/images.lock.json +0 -1750
  62. package/dist/assets/starter/distribution.lock.json +0 -4165
  63. package/dist/assets/starter/images.lock.json +0 -1454
@@ -25,7 +25,7 @@ import { compareStrings } from './string-order.mjs';
25
25
  * rostered biomes that dispatch it. Every unresolvable edge is refused, naming
26
26
  * the consumer and the target.
27
27
  *
28
- * @param {string} context the edition the refusal names, e.g. `distribution "core"`
28
+ * @param {string} context the edition the refusal names, e.g. `distribution "appliance-base"`
29
29
  * @param {object[]} consumers the rostered index entries
30
30
  * @param {object[]} index the index the targets are resolved against
31
31
  */
@@ -73,6 +73,7 @@ import {
73
73
  resolveComposedProcessStructure,
74
74
  resolveImageStructure,
75
75
  sourceTreeLocator,
76
+ supplementalSourcesFor,
76
77
  } from './resolve-images.mjs';
77
78
  import { withoutSealedDigests } from './seal-image-digests.mjs';
78
79
  import { createPinnedSourceAuthority } from './source-authority.mjs';
@@ -301,7 +302,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
301
302
  schemas,
302
303
  requiredPlatforms,
303
304
  resolveDistributionDeclaredSources(schemas, id),
304
- { biomeIndex: pinned.biomeIndex },
305
+ { biomeIndex: pinned.biomeIndex, supplementalSources: supplementalSourcesFor(runtimeSourceIndex) },
305
306
  ),
306
307
  schemas,
307
308
  requiredPlatforms,
@@ -523,7 +524,7 @@ function discoverDistributionIds() {
523
524
  *
524
525
  * Ownership is a property of the platform, not of what an edition preloads.
525
526
  * An earlier design put the claim on each `LockedBiome`, which made a trimmed
526
- * edition (`minimal`, `appliance-lean`) leave `kb`/`agent`/… unowned — so an
527
+ * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
527
528
  * org admin could install the owning biome from the store and THEN bind
528
529
  * `kb:page.read@1` to a URL they control, silently shadowing the platform
529
530
  * data plane. The table is now built from the FULL biome index and reserves
package/edition/lib.mjs CHANGED
@@ -2562,7 +2562,7 @@ export function assertDeclaredDatabasesUnowned(entries) {
2562
2562
  *
2563
2563
  * Ownership is a property of the PLATFORM, not of what an edition happens to
2564
2564
  * preload. If the table were built from the selected set, a trimmed edition
2565
- * (`minimal`, `appliance-lean`) that ships without `knowledge-base` would
2565
+ * (`appliance-lean`) that ships without `knowledge-base` would
2566
2566
  * leave `kb` unowned — and an org admin could then install `knowledge-base`
2567
2567
  * from the store and bind `kb:page.read@1` to a URL they control, silently
2568
2568
  * shadowing the platform data plane. Being AUTHORED by a first-party biome is
@@ -0,0 +1,19 @@
1
+ // map-with-concurrency — run an async worker over a list with a bounded number in flight.
2
+
3
+ /**
4
+ * `worker(item, index)` for every item, at most `limit` at a time; the results
5
+ * keep the items' order. The first rejection rejects the whole call.
6
+ */
7
+ export async function mapWithConcurrency(items, limit, worker) {
8
+ const results = new Array(items.length);
9
+ let cursor = 0;
10
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
11
+ while (cursor < items.length) {
12
+ const index = cursor;
13
+ cursor += 1;
14
+ results[index] = await worker(items[index], index);
15
+ }
16
+ });
17
+ await Promise.all(runners);
18
+ return results;
19
+ }
@@ -0,0 +1,35 @@
1
+ // oras-process — run the oras binary WITHOUT blocking the event loop.
2
+ //
3
+ // A blocking `execFileSync` turns every caller's worker pool into a queue of
4
+ // one: six "concurrent" subjects still ran their registry calls one after the
5
+ // other. This runner spawns the child and resolves on its exit, so the pool's
6
+ // width is the real concurrency.
7
+
8
+ import { execFile } from 'node:child_process';
9
+
10
+ /**
11
+ * Run `file` with `args`; resolve with stdout bytes. A failure rejects with the
12
+ * child's stderr text on `error.stderr` (a string), so the caller reads the
13
+ * registry status from what oras itself wrote.
14
+ */
15
+ export function runExecutable(file, args, { input, env, timeoutMs, maxBufferBytes }) {
16
+ return new Promise((resolve, reject) => {
17
+ const child = execFile(
18
+ file,
19
+ args,
20
+ { env, timeout: timeoutMs, maxBuffer: maxBufferBytes, encoding: 'buffer' },
21
+ (error, stdout, stderr) => {
22
+ if (error) {
23
+ error.stderr = stderr.toString('utf8');
24
+ reject(error);
25
+ return;
26
+ }
27
+ resolve(stdout);
28
+ },
29
+ );
30
+ // A child that exits without reading its stdin must not crash the process
31
+ // with EPIPE; the exit status already carries the failure.
32
+ child.stdin.on('error', () => {});
33
+ child.stdin.end(input);
34
+ });
35
+ }
package/edition/oras.mjs CHANGED
@@ -13,30 +13,36 @@
13
13
  // those bytes; its version must be the one this module's parsing is written
14
14
  // against;
15
15
  // * oras runs under a scrubbed environment (no ambient proxy, config or
16
- // credential variable reaches it) and a fresh EMPTY registry config, so
16
+ // credential variable reaches it) and an EMPTY registry config, so
17
17
  // ambient docker/oras credentials are never used and an authenticated
18
18
  // registry never degrades into an anonymous read;
19
19
  // * the token travels on stdin only (`--password-stdin`), never in argv;
20
20
  // * before oras ever receives a credential, the registry's own `/v2/`
21
21
  // challenge is probed and a token realm on another origin is refused;
22
- // * a digest-addressed read whose bytes do not hash to the requested digest
23
- // throws; a tag read and a digest read are both resolved to the registry's
24
- // descriptor first, and the manifest media type is the descriptor's, never
25
- // the manifest body's optional `mediaType` (Helm's has none);
26
- // * descriptor sizes are checked BEFORE the fetch, actual lengths after it;
22
+ // * a manifest or blob is fetched in ONE call (`--descriptor --output <file>`:
23
+ // the descriptor on stdout, the raw content in a scratch file). Its bytes
24
+ // must hash to the digest the registry described (and, for a digest read,
25
+ // to the requested digest), or the read throws; the manifest media type is
26
+ // the descriptor's, never the manifest body's optional `mediaType` (Helm's
27
+ // has none);
28
+ // * declared size, on-disk size and read length are all held to the cap
29
+ // BEFORE the content is read into memory;
30
+ // * oras runs asynchronously, so a caller's worker pool really runs in parallel;
31
+ // * every call of one registry shares one empty registry config for the run;
27
32
  // * a registry status is read only from oras's own error forms; any other
28
33
  // failure carries no status and is thrown as undecided;
29
34
  // * `--plain-http` and `--insecure` are never passed.
30
35
 
31
- import { execFileSync } from 'node:child_process';
32
- import { createHash } from 'node:crypto';
36
+ import { createHash, randomUUID } from 'node:crypto';
33
37
  import {
34
38
  accessSync,
35
39
  constants,
36
40
  mkdtempSync,
41
+ readFileSync,
37
42
  realpathSync,
38
43
  rmSync,
39
44
  statSync,
45
+ unlinkSync,
40
46
  writeFileSync,
41
47
  } from 'node:fs';
42
48
  import { tmpdir } from 'node:os';
@@ -46,6 +52,7 @@ import { editionWorkspaceConfig } from './edition-config.mjs';
46
52
  import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
47
53
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
48
54
  import { readEvidenceTrustPin } from './evidence-trust-pin.mjs';
55
+ import { runExecutable } from './oras-process.mjs';
49
56
  import {
50
57
  OCI_INDEX_MEDIA_TYPES,
51
58
  REGISTRY_CREDENTIALS_FILE,
@@ -248,18 +255,17 @@ export function prepareTrustedOras({ env = process.env, trustedDigests, notice =
248
255
  /** The default runner: the trusted oras of the configured data root, under the scrubbed environment. */
249
256
  function defaultRunner(trust = configuredOrasTrust) {
250
257
  let prepared;
251
- return (args, { stdin } = {}) => {
258
+ return async (args, { stdin } = {}) => {
252
259
  if (prepared === undefined) {
253
260
  prepared = prepareTrustedOras({ trustedDigests: trust() });
254
261
  process.once('exit', prepared.dispose);
255
262
  }
256
263
  try {
257
- return execFileSync(prepared.path(), args, {
264
+ return await runExecutable(prepared.path(), args, {
258
265
  input: stdin,
259
266
  env: orasEnvironment(prepared.home),
260
- timeout: ORAS_RUN_TIMEOUT_MS,
261
- maxBuffer: ORAS_MAX_BUFFER_BYTES,
262
- stdio: ['pipe', 'pipe', 'pipe'],
267
+ timeoutMs: ORAS_RUN_TIMEOUT_MS,
268
+ maxBufferBytes: ORAS_MAX_BUFFER_BYTES,
263
269
  });
264
270
  } catch (error) {
265
271
  const stderr = error?.stderr ? error.stderr.toString('utf8') : '';
@@ -325,6 +331,38 @@ export function statusOfOrasFailure(text, { ref = '' } = {}) {
325
331
  return null;
326
332
  }
327
333
 
334
+ /**
335
+ * ONE oras working directory per registry authority per process: the (empty)
336
+ * registry config every call of that registry points at, and the scratch
337
+ * files a fetch writes its content to. The config never holds a credential
338
+ * (that goes to oras on stdin, per call), so sharing it carries no secret; it
339
+ * replaces a temp directory made and removed around every single call.
340
+ */
341
+ const registrySessions = new Map();
342
+
343
+ function registrySession(authority) {
344
+ let session = registrySessions.get(authority);
345
+ if (session === undefined) {
346
+ const directory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
347
+ const configPath = join(directory, 'registry-config.json');
348
+ writeFileSync(configPath, '');
349
+ session = { directory, configPath };
350
+ registrySessions.set(authority, session);
351
+ if (registrySessions.size === 1) {
352
+ process.once('exit', disposeRegistrySessions);
353
+ }
354
+ }
355
+ return session;
356
+ }
357
+
358
+ /** Remove every registry session directory; called at process exit, and by tests. */
359
+ export function disposeRegistrySessions() {
360
+ for (const { directory } of registrySessions.values()) {
361
+ rmSync(directory, { recursive: true, force: true });
362
+ }
363
+ registrySessions.clear();
364
+ }
365
+
328
366
  /**
329
367
  * A read-only OCI registry client bound to ONE registry authority and ONE
330
368
  * resolved credential, doing every operation with the pinned oras.
@@ -417,28 +455,58 @@ export class OrasRegistry {
417
455
  return this.#token ? text.split(this.#token).join('***') : text;
418
456
  }
419
457
 
420
- /** One oras invocation with an empty registry config; returns stdout bytes. */
458
+ /** One oras invocation under this registry's shared empty config; returns stdout bytes. */
421
459
  async #oras(subcommand, flags, ref) {
422
460
  await this.#ensureRealmIsSameOrigin();
423
- const configDirectory = mkdtempSync(join(tmpdir(), 'xema-oras-'));
424
- const configPath = join(configDirectory, 'registry-config.json');
461
+ const { configPath } = registrySession(this.authority);
462
+ const args = [...subcommand, '--registry-config', configPath];
463
+ let stdin;
464
+ if (this.#credential.access === 'authenticated') {
465
+ args.push('--username', this.#username, '--password-stdin');
466
+ stdin = this.#token;
467
+ }
468
+ args.push(...flags, ref);
469
+ try {
470
+ const out = await this.#run(args, { stdin });
471
+ return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
472
+ } catch (error) {
473
+ throw this.#failure(ref, error);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * ONE oras call that returns the descriptor (stdout) AND the raw content
479
+ * (written to a scratch file by `--output`): the call that used to be a
480
+ * descriptor round trip followed by a content round trip. The content is
481
+ * read into memory only after both its declared and its on-disk size are
482
+ * inside `maxBytes`.
483
+ */
484
+ async #fetchContent(subcommand, ref, maxBytes, acceptDescriptor) {
485
+ const { directory } = registrySession(this.authority);
486
+ const outputPath = join(directory, `content-${randomUUID()}`);
425
487
  try {
426
- writeFileSync(configPath, '');
427
- const args = [...subcommand, '--registry-config', configPath];
428
- let stdin;
429
- if (this.#credential.access === 'authenticated') {
430
- args.push('--username', this.#username, '--password-stdin');
431
- stdin = this.#token;
488
+ const described = await this.#oras(subcommand, ['--descriptor', '--output', outputPath], ref);
489
+ const descriptor = this.#parseDescriptor(described, ref);
490
+ acceptDescriptor(descriptor);
491
+ if (descriptor.size > maxBytes) {
492
+ throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
432
493
  }
433
- args.push(...flags, ref);
434
- try {
435
- const out = await this.#run(args, { stdin });
436
- return Buffer.isBuffer(out) ? out : Buffer.from(out ?? '');
437
- } catch (error) {
438
- throw this.#failure(ref, error);
494
+ const onDisk = statSync(outputPath).size;
495
+ if (onDisk > maxBytes) {
496
+ throw new Error(`${ref} returned more than ${maxBytes} bytes`);
497
+ }
498
+ const bytes = readFileSync(outputPath);
499
+ const actual = sha256DigestOf(bytes);
500
+ if (actual !== descriptor.digest) {
501
+ throw new Error(`${ref} returned content addressed ${actual}, not the digest ${descriptor.digest} it described`);
439
502
  }
503
+ return { descriptor, bytes };
440
504
  } finally {
441
- rmSync(configDirectory, { recursive: true, force: true });
505
+ try {
506
+ unlinkSync(outputPath);
507
+ } catch {
508
+ // No content file exists when the fetch itself failed; the failure is already thrown.
509
+ }
442
510
  }
443
511
  }
444
512
 
@@ -555,88 +623,49 @@ export class OrasRegistry {
555
623
  }
556
624
 
557
625
  /**
558
- * The descriptor the registry reports for a manifest reference, refused when
559
- * its media type is not an accepted manifest type or it declares more than
560
- * the manifest size cap.
626
+ * Fetch a manifest in ONE call and hold it to everything a separate
627
+ * descriptor call used to: its media type is an accepted manifest type, its
628
+ * size is inside the cap, and its bytes hash to the digest the registry
629
+ * described. `expectedDigest`, when the caller asked by digest, must be that
630
+ * digest. The media type is the descriptor's, never the body's optional
631
+ * `mediaType` (Helm's has none).
561
632
  */
562
- async #manifestDescriptor(ref) {
563
- const out = await this.#oras(['manifest', 'fetch'], ['--descriptor'], ref);
564
- const descriptor = this.#parseDescriptor(out, ref);
565
- if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(descriptor.mediaType)) {
566
- throw new Error(
567
- `${ref} returned unsupported manifest media type "${descriptor.mediaType}"`,
568
- );
569
- }
570
- if (descriptor.size > MAX_MANIFEST_BYTES) {
571
- throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${MAX_MANIFEST_BYTES}`);
572
- }
573
- return descriptor;
633
+ async #manifest(ref, expectedDigest) {
634
+ const { descriptor, bytes } = await this.#fetchContent(['manifest', 'fetch'], ref, MAX_MANIFEST_BYTES, (described) => {
635
+ if (!ACCEPTED_MANIFEST_MEDIA_TYPES.includes(described.mediaType)) {
636
+ throw new Error(`${ref} returned unsupported manifest media type "${described.mediaType}"`);
637
+ }
638
+ if (expectedDigest !== undefined && described.digest !== expectedDigest) {
639
+ throw new Error(`${ref} described ${described.digest}, not the requested ${expectedDigest}`);
640
+ }
641
+ });
642
+ return { ...decodeManifest(bytes, descriptor.mediaType, ref), digest: descriptor.digest, bytes };
574
643
  }
575
644
 
576
- /**
577
- * Fetch a manifest by TAG: resolve the descriptor, then read by digest, so the
578
- * bytes are always hash-checked against what the registry said the tag is.
579
- */
645
+ /** Fetch a manifest by TAG: one call, the content verified against the digest the registry described. */
580
646
  async getManifestByTag(repository, tag) {
581
- const descriptor = await this.#manifestDescriptor(`${this.#ref(repository)}:${tag}`);
582
- return this.#manifestByDigest(repository, descriptor.digest, descriptor.mediaType);
647
+ return this.#manifest(`${this.#ref(repository)}:${tag}`);
583
648
  }
584
649
 
585
- /**
586
- * Fetch a manifest by DIGEST: the media type is the one the registry reports
587
- * for that digest (a manifest body's own `mediaType` is optional, and Helm's
588
- * has none), and the bytes must hash to exactly that digest.
589
- */
650
+ /** Fetch a manifest by DIGEST: one call, the bytes must hash to exactly that digest. */
590
651
  async getManifestByDigest(repository, digest) {
591
652
  if (!SHA256_DIGEST_PATTERN.test(digest)) {
592
653
  throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
593
654
  }
594
- const ref = `${this.#ref(repository)}@${digest}`;
595
- const descriptor = await this.#manifestDescriptor(ref);
596
- if (descriptor.digest !== digest) {
597
- throw new Error(`${ref} described ${descriptor.digest}, not the requested ${digest}`);
598
- }
599
- return this.#manifestByDigest(repository, digest, descriptor.mediaType);
600
- }
601
-
602
- async #manifestByDigest(repository, digest, expectedMediaType) {
603
- if (!SHA256_DIGEST_PATTERN.test(digest)) {
604
- throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
605
- }
606
- const ref = `${this.#ref(repository)}@${digest}`;
607
- const bytes = await this.#oras(['manifest', 'fetch'], [], ref);
608
- if (bytes.length > MAX_MANIFEST_BYTES) {
609
- throw new Error(`${ref} returned more than ${MAX_MANIFEST_BYTES} bytes`);
610
- }
611
- const actual = sha256DigestOf(bytes);
612
- if (actual !== digest) {
613
- throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
614
- }
615
- return { ...decodeManifest(bytes, expectedMediaType, ref), digest, bytes };
655
+ return this.#manifest(`${this.#ref(repository)}@${digest}`, digest);
616
656
  }
617
657
 
618
- /** Fetch a blob by DIGEST; the descriptor size is checked before the fetch, the bytes after. */
658
+ /** Fetch a blob by DIGEST in one call; its declared and actual size are bounded by `maxBytes`, its bytes must hash to that digest. */
619
659
  async getBlob(repository, digest, maxBytes = MAX_CONFIG_BLOB_BYTES) {
620
660
  if (!SHA256_DIGEST_PATTERN.test(digest)) {
621
661
  throw new Error(`"${digest}" is not a canonical lowercase sha256 digest`);
622
662
  }
623
663
  const ref = `${this.#ref(repository)}@${digest}`;
624
- const described = await this.#oras(['blob', 'fetch'], ['--descriptor'], ref);
625
- const descriptor = this.#parseDescriptor(described, ref);
626
- if (descriptor.digest !== digest) {
627
- throw new Error(`${ref} described ${descriptor.digest}, not the requested ${digest}`);
628
- }
629
- if (descriptor.size > maxBytes) {
630
- throw new Error(`${ref} declares ${descriptor.size} bytes, exceeding ${maxBytes}`);
631
- }
632
- const bytes = await this.#oras(['blob', 'fetch'], ['--output', '-'], ref);
633
- if (bytes.length > maxBytes) {
634
- throw new Error(`${ref} returned more than ${maxBytes} bytes`);
635
- }
636
- const actual = sha256DigestOf(bytes);
637
- if (actual !== digest) {
638
- throw new Error(`${ref} returned content addressed ${actual}, not the requested ${digest}`);
639
- }
664
+ const { bytes } = await this.#fetchContent(['blob', 'fetch'], ref, maxBytes, (described) => {
665
+ if (described.digest !== digest) {
666
+ throw new Error(`${ref} described ${described.digest}, not the requested ${digest}`);
667
+ }
668
+ });
640
669
  return bytes;
641
670
  }
642
671
  }
@@ -69,6 +69,7 @@ import {
69
69
  import { vendorChartSetConfig } from './edition-config.mjs';
70
70
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule } from './edition-workspace.mjs';
71
71
  import { serializeJson } from './lib.mjs';
72
+ import { mapWithConcurrency } from './map-with-concurrency.mjs';
72
73
  import { OrasRegistry } from './oras.mjs';
73
74
  import {
74
75
  loadRegistryCredentialDeclarations,
@@ -141,6 +142,9 @@ export function selectNewestIndex(candidates) {
141
142
 
142
143
  const DIGEST_PATTERN = OCI_DIGEST_PATTERN;
143
144
 
145
+ /** Registry reads in flight at once while reading a chart set. */
146
+ const CHART_FETCH_CONCURRENCY = 6;
147
+
144
148
  /** The index chart annotation listing the charts the publisher closed the set with. */
145
149
  export const INDEX_ROSTER_ANNOTATION = 'xema.dev/chart-set-charts';
146
150
 
@@ -226,8 +230,10 @@ export function parseIndexRoster(annotation, label) {
226
230
  */
227
231
  export async function sealRoster(roster, lookup, label) {
228
232
  const sealed = {};
229
- for (const [name, member] of Object.entries(roster)) {
230
- const registryDigest = await lookup(name, member.version);
233
+ const members = Object.entries(roster);
234
+ const registryDigests = await mapWithConcurrency(members, CHART_FETCH_CONCURRENCY, ([name, member]) => lookup(name, member.version));
235
+ for (const [position, [name, member]] of members.entries()) {
236
+ const registryDigest = registryDigests[position];
231
237
  if (!DIGEST_PATTERN.test(String(registryDigest))) {
232
238
  throw new Error(`${label}: registry returned no sha256 manifest digest for ${name}:${member.version}`);
233
239
  }
@@ -290,16 +296,14 @@ export function readLock(path = chartSetLockPath()) {
290
296
  async function indexCandidates(client, indexName) {
291
297
  const repository = registryRepository(indexName);
292
298
  const tags = (await client.listTags(repository)).filter(isVersionTag);
293
- const candidates = [];
294
- for (const tag of tags) {
299
+ return mapWithConcurrency(tags, CHART_FETCH_CONCURRENCY, async (tag) => {
295
300
  const manifest = await client.getManifestByTag(repository, tag);
296
301
  const document = JSON.parse(manifest.bytes.toString('utf8'));
297
- candidates.push({
302
+ return {
298
303
  tag,
299
304
  createdAt: document?.annotations?.['org.opencontainers.image.created'] ?? null,
300
- });
301
- }
302
- return candidates;
305
+ };
306
+ });
303
307
  }
304
308
 
305
309
  /** Read every publisher's newest index through `client`: its version, its roster, and the manifest digest the registry served for it. */