@wavehouse/chtypes 2.0.0-dev.1 → 2.0.0-dev.11

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 (145) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -1
  3. package/dist/abi2/buildinfo.d.ts +1 -1
  4. package/dist/abi2/buildinfo.js +3 -3
  5. package/dist/abi2/buildinfo.js.map +1 -1
  6. package/dist/abi2/calls.gen.d.ts +5 -5
  7. package/dist/abi2/calls.gen.d.ts.map +1 -1
  8. package/dist/abi2/calls.gen.js +11 -11
  9. package/dist/abi2/calls.gen.js.map +1 -1
  10. package/dist/abi2/decls.gen.d.ts +2 -1
  11. package/dist/abi2/decls.gen.d.ts.map +1 -1
  12. package/dist/abi2/decls.gen.js +78 -20
  13. package/dist/abi2/decls.gen.js.map +1 -1
  14. package/dist/abi2/errmap.gen.js +1 -1
  15. package/dist/abi2/errors.d.ts +21 -40
  16. package/dist/abi2/errors.d.ts.map +1 -1
  17. package/dist/abi2/errors.js +20 -44
  18. package/dist/abi2/errors.js.map +1 -1
  19. package/dist/abi2/index.d.ts +2 -2
  20. package/dist/abi2/index.d.ts.map +1 -1
  21. package/dist/abi2/index.js +1 -1
  22. package/dist/abi2/index.js.map +1 -1
  23. package/dist/abi2/libc.gen.d.ts +2 -0
  24. package/dist/abi2/libc.gen.d.ts.map +1 -1
  25. package/dist/abi2/libc.gen.js +11 -1
  26. package/dist/abi2/libc.gen.js.map +1 -1
  27. package/dist/abi2/loader.d.ts +4 -2
  28. package/dist/abi2/loader.d.ts.map +1 -1
  29. package/dist/abi2/loader.js +2 -2
  30. package/dist/abi2/loader.js.map +1 -1
  31. package/dist/abi2/raw.d.ts.map +1 -1
  32. package/dist/abi2/raw.js +4 -1
  33. package/dist/abi2/raw.js.map +1 -1
  34. package/dist/abi2/vocab.gen.d.ts +90 -1
  35. package/dist/abi2/vocab.gen.d.ts.map +1 -1
  36. package/dist/abi2/vocab.gen.js +144 -4
  37. package/dist/abi2/vocab.gen.js.map +1 -1
  38. package/dist/cli.d.ts +11 -5
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +130 -20
  41. package/dist/cli.js.map +1 -1
  42. package/dist/documents.d.ts +100 -7
  43. package/dist/documents.d.ts.map +1 -1
  44. package/dist/documents.js +135 -8
  45. package/dist/documents.js.map +1 -1
  46. package/dist/env.d.ts +8 -1
  47. package/dist/env.d.ts.map +1 -1
  48. package/dist/env.js +17 -8
  49. package/dist/env.js.map +1 -1
  50. package/dist/index.d.ts +10 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -5
  53. package/dist/index.js.map +1 -1
  54. package/dist/library.d.ts +13 -2
  55. package/dist/library.d.ts.map +1 -1
  56. package/dist/library.js +24 -5
  57. package/dist/library.js.map +1 -1
  58. package/dist/ocifetch/channel.d.ts +84 -0
  59. package/dist/ocifetch/channel.d.ts.map +1 -1
  60. package/dist/ocifetch/channel.js +157 -2
  61. package/dist/ocifetch/channel.js.map +1 -1
  62. package/dist/ocifetch/constants.gen.d.ts +28 -0
  63. package/dist/ocifetch/constants.gen.d.ts.map +1 -1
  64. package/dist/ocifetch/constants.gen.js +30 -1
  65. package/dist/ocifetch/constants.gen.js.map +1 -1
  66. package/dist/ocifetch/dsse.d.ts +5 -1
  67. package/dist/ocifetch/dsse.d.ts.map +1 -1
  68. package/dist/ocifetch/dsse.js +10 -3
  69. package/dist/ocifetch/dsse.js.map +1 -1
  70. package/dist/ocifetch/ensure.d.ts +68 -8
  71. package/dist/ocifetch/ensure.d.ts.map +1 -1
  72. package/dist/ocifetch/ensure.js +305 -66
  73. package/dist/ocifetch/ensure.js.map +1 -1
  74. package/dist/ocifetch/errors.d.ts +101 -25
  75. package/dist/ocifetch/errors.d.ts.map +1 -1
  76. package/dist/ocifetch/errors.js +74 -36
  77. package/dist/ocifetch/errors.js.map +1 -1
  78. package/dist/ocifetch/hold.d.ts +66 -0
  79. package/dist/ocifetch/hold.d.ts.map +1 -0
  80. package/dist/ocifetch/hold.js +150 -0
  81. package/dist/ocifetch/hold.js.map +1 -0
  82. package/dist/ocifetch/http.d.ts +10 -2
  83. package/dist/ocifetch/http.d.ts.map +1 -1
  84. package/dist/ocifetch/http.js +31 -12
  85. package/dist/ocifetch/http.js.map +1 -1
  86. package/dist/ocifetch/index.d.ts +10 -5
  87. package/dist/ocifetch/index.d.ts.map +1 -1
  88. package/dist/ocifetch/index.js +8 -3
  89. package/dist/ocifetch/index.js.map +1 -1
  90. package/dist/ocifetch/localverify.d.ts +3 -1
  91. package/dist/ocifetch/localverify.d.ts.map +1 -1
  92. package/dist/ocifetch/localverify.js +6 -1
  93. package/dist/ocifetch/localverify.js.map +1 -1
  94. package/dist/ocifetch/lock.d.ts +2 -1
  95. package/dist/ocifetch/lock.d.ts.map +1 -1
  96. package/dist/ocifetch/lock.js +7 -5
  97. package/dist/ocifetch/lock.js.map +1 -1
  98. package/dist/ocifetch/oci.d.ts +45 -3
  99. package/dist/ocifetch/oci.d.ts.map +1 -1
  100. package/dist/ocifetch/oci.js +125 -58
  101. package/dist/ocifetch/oci.js.map +1 -1
  102. package/dist/ocifetch/prune.d.ts +48 -0
  103. package/dist/ocifetch/prune.d.ts.map +1 -0
  104. package/dist/ocifetch/prune.js +383 -0
  105. package/dist/ocifetch/prune.js.map +1 -0
  106. package/dist/ocifetch/referrers.d.ts +24 -0
  107. package/dist/ocifetch/referrers.d.ts.map +1 -1
  108. package/dist/ocifetch/referrers.js +92 -5
  109. package/dist/ocifetch/referrers.js.map +1 -1
  110. package/dist/ocifetch/resolve.d.ts +37 -0
  111. package/dist/ocifetch/resolve.d.ts.map +1 -0
  112. package/dist/ocifetch/resolve.js +90 -0
  113. package/dist/ocifetch/resolve.js.map +1 -0
  114. package/dist/ocifetch/retired.d.ts +61 -0
  115. package/dist/ocifetch/retired.d.ts.map +1 -0
  116. package/dist/ocifetch/retired.js +236 -0
  117. package/dist/ocifetch/retired.js.map +1 -0
  118. package/dist/ocifetch/revocation.d.ts +151 -0
  119. package/dist/ocifetch/revocation.d.ts.map +1 -0
  120. package/dist/ocifetch/revocation.js +481 -0
  121. package/dist/ocifetch/revocation.js.map +1 -0
  122. package/dist/ocifetch/types.d.ts +18 -2
  123. package/dist/ocifetch/types.d.ts.map +1 -1
  124. package/dist/ocifetch/types.js.map +1 -1
  125. package/dist/registry.d.ts +80 -3
  126. package/dist/registry.d.ts.map +1 -1
  127. package/dist/registry.js +168 -9
  128. package/dist/registry.js.map +1 -1
  129. package/dist/schema.d.ts +52 -9
  130. package/dist/schema.d.ts.map +1 -1
  131. package/dist/schema.js +6 -6
  132. package/dist/schema.js.map +1 -1
  133. package/dist/server.d.ts +27 -0
  134. package/dist/server.d.ts.map +1 -0
  135. package/dist/server.js +35 -0
  136. package/dist/server.js.map +1 -0
  137. package/dist/settings.d.ts +101 -9
  138. package/dist/settings.d.ts.map +1 -1
  139. package/dist/settings.js +201 -27
  140. package/dist/settings.js.map +1 -1
  141. package/dist/setup.d.ts +3 -2
  142. package/dist/setup.d.ts.map +1 -1
  143. package/dist/setup.js +2 -3
  144. package/dist/setup.js.map +1 -1
  145. package/package.json +2 -2
@@ -3,21 +3,26 @@
3
3
  * `listInstalled`, `verifyInstalled`, `fetchSigned`. This is the only file
4
4
  * that wires resolve (`oci.ts`), trust (`dsse.ts`), bytes (`unpack.ts`), the
5
5
  * cache (`layout.ts`) and the lock (`lock.ts`) together; nothing outside
6
- * `index.ts` calls into those modules directly.
6
+ * `index.ts` calls into those modules directly. `resolve.ts` (`chtypes
7
+ * resolve`) takes the same resolve and trust steps, through
8
+ * `verifyManifestTrust`, and `prune.ts` (`chtypes prune`) reads and removes
9
+ * cache entries under the in-use hold (`hold.ts`).
7
10
  */
8
11
  import { randomBytes } from 'node:crypto';
9
12
  import { unlink } from 'node:fs/promises';
10
13
  import os from 'node:os';
11
14
  import path from 'node:path';
12
15
  import { asString, field, items, parseJsonValue } from '../json.js';
13
- import { activeChannel, allowUnsigned, noteIgnoredOverrides, refusePinning, warnIgnored } from './channel.js';
16
+ import { activeChannel, aheadOfRegistry, allowUnsigned, noteIgnoredOverrides, offlineMode, refusePinning, visibleToChannel, warnIgnored } from './channel.js';
14
17
  import { BASE_SEPARATOR, CACHE_ANNOTATION_PREFIX, ENV_BASES_NAME, GOLDENS_ARTIFACT_TYPE, MEDIA_TYPE_BUNDLE, MEDIA_TYPE_MANIFEST, PREDICATE_TYPE_ARTIFACT, PREDICATE_TYPE_GOLDENS, SPELLING_REGEX, TAGS_LIST_MAX_BYTES, } from './constants.gen.js';
15
18
  import { checkArtifactStatement, checkGenericStatement, parseStatement, verifyAnyReferrerBundle, verifyBundleSignature } from './dsse.js';
16
19
  import { ArtifactCorruptError, ArtifactMissingError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, FetchV1Error, SourceUnreachableError, } from './errors.js';
17
20
  import { readFileUrl, requestBuffered } from './http.js';
18
21
  import { buildRecord, cacheRoot, commitStaging, ensureLayout, freshStagingDir, installBlob, listVerified, readIndexEntries, readVerifiedRecord, removeStaging, systemDirs, unpackedDir, upsertIndexEntry, writeVerifiedRecord, zeroXHint, } from './layout.js';
19
22
  import { selectGoldens, statementRevision } from './goldens.js';
20
- import { discoverSignatureCandidates } from './referrers.js';
23
+ import { discoverSignatureCandidates, readListing } from './referrers.js';
24
+ import { isGone } from './retired.js';
25
+ import { checkCandidates, checkFrozenInstalled, checkInstalledOnce, checkInstalledStored, checkListing, revocationOn, settle, storedRevocation, storePending, subjectOfStatement, } from './revocation.js';
21
26
  import { probeRoots, strictMode, unwritable } from './faults.js';
22
27
  import { verifyAndInstallFromLocalBlobs } from './localverify.js';
23
28
  import { fetchBlobBytesByDigest, fetchManifestByDigest, resolveTag } from './oci.js';
@@ -32,7 +37,7 @@ function defaultTrustedKeys(options) {
32
37
  return channel.keys;
33
38
  }
34
39
  /** The bases: the caller's or `CHTYPES_ARTIFACTS_URL` when the active contract honors them, else the contract's own (the staging dev channel alone, rule r6). */
35
- function resolveBases(options) {
40
+ export function resolveBases(options) {
36
41
  const channel = activeChannel();
37
42
  if (!channel.overridable)
38
43
  return channel.bases;
@@ -70,13 +75,53 @@ function tokenHostsFor(bases) {
70
75
  }
71
76
  return hosts;
72
77
  }
73
- function requestOptionsFor(options, bases) {
78
+ /** The request options every fetch over `bases` sends: the clock, and the token for the configured base hosts only. */
79
+ export function requestOptionsFor(options, bases) {
74
80
  return {
75
81
  clock: options.clock ?? realClock(),
76
82
  ...(options.token !== undefined ? { token: options.token } : {}),
77
83
  tokenHosts: tokenHostsFor(bases),
78
84
  };
79
85
  }
86
+ /**
87
+ * What one revocation check reads from `options` (`revocation.ts`). The override
88
+ * is per call and forensic: it never lets a lock or an update pin a revoked build.
89
+ */
90
+ export function revocationContext(options) {
91
+ const bases = resolveBases(options);
92
+ return {
93
+ bases,
94
+ trustedKeys: defaultTrustedKeys(options),
95
+ request: requestOptionsFor(options, bases),
96
+ cacheRoot: cacheRoot(options.cacheDir),
97
+ offline: offlineMode(options.offline),
98
+ allowRevoked: options.allowRevoked === true && options.lockWrite !== true && options.update !== true,
99
+ requireRevocationCheck: options.requireRevocationCheck === true,
100
+ };
101
+ }
102
+ /** `res` with `warnings` added to its own. */
103
+ function withWarnings(res, warnings) {
104
+ return warnings.length === 0 ? res : { ...res, warnings: [...res.warnings, ...warnings] };
105
+ }
106
+ /**
107
+ * The installed-build revocation check a registry makes after `resolveInstalled`
108
+ * answers and before it loads the build (guide §9, `check_installed`): the stored
109
+ * record, then, unless offline, at most one check per build per process. The
110
+ * warnings the open prints are returned even when the build is refused, as Go
111
+ * returns them beside the error. Under the v1 contract it does nothing.
112
+ */
113
+ export async function checkInstalled(res, options = {}) {
114
+ const warnings = [];
115
+ try {
116
+ await checkInstalledOnce(revocationContext(options), res, warnings);
117
+ }
118
+ catch (err) {
119
+ if (err instanceof FetchV1Error)
120
+ return { warnings, error: err };
121
+ throw err;
122
+ }
123
+ return { warnings, error: undefined };
124
+ }
80
125
  /** Builds the `Resolved` the seam promises, from a `verified.json` record plus where it came from. */
81
126
  function resolvedFromRecord(record, platform, request, dir, source, alreadyInstalled, warnings) {
82
127
  return {
@@ -134,6 +179,9 @@ export async function resolveInstalled(request, platform, options = {}) {
134
179
  continue;
135
180
  if (!withinRequest(record.version, request))
136
181
  continue;
182
+ // Under the dev channel, only its own fingerprint's builds (`visibleToChannel`).
183
+ if (!visibleToChannel(record.predicate))
184
+ continue;
137
185
  candidates.push({ record, dir, source: r.source, rank });
138
186
  }
139
187
  }
@@ -143,10 +191,35 @@ export async function resolveInstalled(request, platform, options = {}) {
143
191
  const best = candidates[0];
144
192
  return resolvedFromRecord(best.record, platform, request, best.dir, best.source, true, warnings);
145
193
  }
194
+ /**
195
+ * The build id of the newest cached record of this SDK's OWN fingerprint that
196
+ * answers the request (`resolveInstalled`'s filter), `''` when it names none,
197
+ * or `undefined` when the cache holds no such record (public issue #581).
198
+ */
199
+ async function cachedOwnBuild(options, platform, request) {
200
+ let best;
201
+ for (const r of searchRoots(options)) {
202
+ for (const { record } of await listVerified(r.root)) {
203
+ if (record.platform !== platform)
204
+ continue;
205
+ if (!withinRequest(record.version, request))
206
+ continue;
207
+ if (!visibleToChannel(record.predicate))
208
+ continue;
209
+ if (best === undefined || compareRecords(record, best) > 0)
210
+ best = record;
211
+ }
212
+ }
213
+ return best === undefined ? undefined : best.build;
214
+ }
146
215
  /** The cache, then every system directory in order: the one search order every lookup uses. */
147
216
  function searchRoots(options) {
148
217
  return [{ root: cacheRoot(options.cacheDir), source: 'cache' }, ...systemDirs(options.systemDirs).map((d) => ({ root: d, source: `system:${d}` }))];
149
218
  }
219
+ /** The directories every lookup reads, in order: the cache root, then each system directory (public issue #530). It reads and creates nothing. */
220
+ export function searchDirs(options = {}) {
221
+ return searchRoots(options).map((r) => r.root);
222
+ }
150
223
  /** Two records by (version, build): the version numerically, part by part, then the fixed-width build. */
151
224
  function compareRecords(a, b) {
152
225
  const av = a.version.split('.').map(Number);
@@ -205,7 +278,7 @@ async function verifyPreseededEntries(sourceDir, writeRoot, platform, trustedKey
205
278
  const already = await readVerifiedRecord(unpackedDir(writeRoot, hexOfDigest(entry.digest)));
206
279
  if (already !== undefined)
207
280
  continue;
208
- await verifyAndInstallFromLocalBlobs(sourceDir, writeRoot, entry.digest, platform, trustedKeys);
281
+ await verifyAndInstallFromLocalBlobs(sourceDir, writeRoot, entry.digest, platform, trustedKeys, true);
209
282
  }
210
283
  }
211
284
  /**
@@ -254,6 +327,10 @@ function compareVersionThenBuild(a, b) {
254
327
  * answered only by that exact version, so for it only a newer BUILD of the
255
328
  * same version counts. A request that is not a version spelling (an
256
329
  * arbitrary tag) names no range, so nothing is kept for it.
330
+ *
331
+ * Under the dev channel only an install of its own fingerprint counts
332
+ * (`visibleToChannel`): a newer build of another fingerprint, installed by
333
+ * another dev SDK, is never kept over this one's own.
257
334
  */
258
335
  async function checkMonotonic(root, platform, request, incoming) {
259
336
  if (!new RegExp(SPELLING_REGEX).test(request))
@@ -264,6 +341,8 @@ async function checkMonotonic(root, platform, request, incoming) {
264
341
  continue;
265
342
  if (!withinRequest(record.version, request))
266
343
  continue;
344
+ if (!visibleToChannel(record.predicate))
345
+ continue;
267
346
  if (compareVersionThenBuild(incoming, record.predicate) >= 0)
268
347
  continue;
269
348
  if (best === undefined || compareVersionThenBuild(best.record.predicate, record.predicate) < 0)
@@ -295,17 +374,22 @@ export async function listInstalled(options = {}) {
295
374
  export async function verifyInstalled(options = {}) {
296
375
  noteIgnoredOverrides(options);
297
376
  await probeCache(options);
377
+ const rctx = revocationContext(options);
298
378
  const out = [];
299
379
  for (const r of searchRoots(options)) {
300
380
  for (const { dir, record } of await listVerified(r.root)) {
301
381
  const platform = record.platform;
382
+ let result;
302
383
  try {
303
384
  await verifyInstalledLibrary(path.join(dir, record.library), record.librarySha256, record.libraryBytes);
304
- out.push({ platform, dir, ok: true, detail: '' });
385
+ result = { platform, version: record.version, dir, ok: true, detail: '' };
305
386
  }
306
387
  catch (err) {
307
- out.push({ platform, dir, ok: false, detail: err instanceof Error ? err.message : String(err) });
388
+ result = { platform, version: record.version, dir, ok: false, detail: err instanceof Error ? err.message : String(err) };
308
389
  }
390
+ // A build a stored revocation refuses is reported as revoked, whatever its bytes say.
391
+ const stored = revocationOn() ? await storedRevocation(rctx, resolvedFromRecord(record, platform, record.version, dir, r.source, true, [])) : undefined;
392
+ out.push(stored === undefined ? result : { ...result, detail: stored.text, revocation: stored.revocation });
309
393
  }
310
394
  }
311
395
  return out;
@@ -336,12 +420,18 @@ async function ensureIn(request, options) {
336
420
  const root = cacheRoot(options.cacheDir);
337
421
  // An offline lookup is read-only: it creates nothing, so a read-only mount
338
422
  // reads cleanly (guide §6; public issue #486). Only a fetch makes the layout.
339
- if (options.offline === true) {
423
+ if (offlineMode(options.offline)) {
424
+ // Offline makes no request, so only a stored revocation can refuse it (guide,
425
+ // "Build-level revocation").
426
+ if (options.frozen === true) {
427
+ const frozenHit = await resolveFrozenOffline(request, platform, options);
428
+ return withWarnings(frozenHit, await checkInstalledStored(revocationContext(options), frozenHit));
429
+ }
340
430
  const hit = await resolveInstalled(request, platform, options);
341
431
  if (hit === undefined) {
342
432
  throw new ArtifactMissingError(withNotes(`chtypes: ${request} (${platform}) is not installed, and --offline forbids fetching it`, await missingNotes(options)));
343
433
  }
344
- return hit;
434
+ return withWarnings(hit, await checkInstalledStored(revocationContext(options), hit));
345
435
  }
346
436
  if (strictMode(options.strictCache)) {
347
437
  // Strict: the cache is checked before anything is fetched into it.
@@ -354,6 +444,7 @@ async function ensureIn(request, options) {
354
444
  const bases = resolveBases(options);
355
445
  const baseReqOptions = requestOptionsFor(options, bases);
356
446
  const trustedKeys = defaultTrustedKeys(options);
447
+ const rctx = revocationContext(options);
357
448
  const resolveResult = await resolveTag(bases, request, platform, { ...baseReqOptions, maxBytes: 0 });
358
449
  const manifestHex = hexOfDigest(resolveResult.manifest.digest);
359
450
  const finalDir = unpackedDir(root, manifestHex);
@@ -362,7 +453,16 @@ async function ensureIn(request, options) {
362
453
  let recordDir = finalDir;
363
454
  let warnings = [];
364
455
  let alreadyInstalled;
456
+ // The build is answered from the cache with no download, so it gets the
457
+ // installed-build revocation check (guide, "Build-level revocation").
458
+ let usedInstalled = false;
459
+ // The revocation an override let this call install past, stored beside it.
460
+ let pending;
365
461
  if (existing !== undefined) {
462
+ // An install of another fingerprint's build, reached through the tag
463
+ // because the registry has none at this SDK's own, is never reported
464
+ // installed (public issue #578): refused before the cache is touched.
465
+ await aheadOfRegistry(resolveResult.aliasAbsent, existing.predicate, () => cachedOwnBuild(options, platform, request));
366
466
  record = existing;
367
467
  alreadyInstalled = true;
368
468
  // A NEWER build within the request, installed beside it, is still the
@@ -377,29 +477,21 @@ async function ensureIn(request, options) {
377
477
  recordDir = monotonic.dir;
378
478
  warnings = [...warnings, monotonic.warning];
379
479
  }
480
+ usedInstalled = true;
380
481
  }
381
482
  else {
382
- const info = platformInfo(platform);
383
- const trust = await verifyAnyReferrerBundle(resolveResult.repositoryRoot, resolveResult.manifest.digest, MEDIA_TYPE_BUNDLE, trustedKeys, (statement) => checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, resolveResult.manifest.layer.digest, {
384
- os: info.os,
385
- arch: info.architecture,
386
- requestedSpelling: request,
387
- }), { ...baseReqOptions, maxBytes: 0 });
388
- let predicate;
389
- let signedBy;
390
- if (trust === undefined) {
391
- if (!allowUnsigned(options)) {
392
- throw new ArtifactUntrustedError(activeChannel().overridable
393
- ? `chtypes: no referrer of ${resolveResult.manifest.digest} verified under a trusted key (CHTYPES_ALLOW_UNSIGNED=1 to proceed anyway)`
394
- : `chtypes: no referrer of ${resolveResult.manifest.digest} verified under the staging key ${activeChannel().keys.map((k) => k.keyid).join(', ')}`);
395
- }
396
- predicate = await unsignedPredicateFallback(resolveResult.repositoryRoot, resolveResult.manifest, request, info, baseReqOptions);
397
- signedBy = '';
398
- warnings = [...warnings, 'chtypes: proceeding with an unsigned artifact (CHTYPES_ALLOW_UNSIGNED)'];
399
- }
400
- else {
401
- predicate = trust.statement.predicate;
402
- signedBy = trust.signedBy;
483
+ const verified = await verifyManifestTrust(resolveResult.repositoryRoot, resolveResult.manifest, resolveResult.aliasAbsent, platform, request, options, baseReqOptions);
484
+ const { trust, predicate, signedBy } = verified;
485
+ warnings = [...warnings, ...verified.warnings];
486
+ // A revocation of the candidate, from the trust listing just read (no extra
487
+ // listing request), refuses it before the monotonic rule and before its layer
488
+ // is requested: a revoked build is never downloaded. With the override, it is
489
+ // installed, and its revocation stored beside it.
490
+ if (revocationOn() && trust !== undefined) {
491
+ const sub = subjectOfStatement(trust.statement, resolveResult.manifest.digest, resolveResult.manifest.layer.digest, platform, request);
492
+ const out = await checkCandidates(rctx, [resolveResult.repositoryRoot], verified.listing.revocations, sub, false);
493
+ await settle(rctx, sub, out, false, warnings);
494
+ pending = out.verified;
403
495
  }
404
496
  const monotonic = await checkMonotonic(root, platform, request, predicate);
405
497
  if (monotonic !== undefined) {
@@ -412,6 +504,7 @@ async function ensureIn(request, options) {
412
504
  recordDir = monotonic.dir;
413
505
  alreadyInstalled = true;
414
506
  warnings = [...warnings, monotonic.warning];
507
+ usedInstalled = true;
415
508
  }
416
509
  else {
417
510
  const staging = await freshStagingDir(root);
@@ -453,19 +546,60 @@ async function ensureIn(request, options) {
453
546
  const manifestDescriptor = { mediaType: MEDIA_TYPE_MANIFEST, digest: resolveResult.manifest.digest };
454
547
  await upsertIndexEntry(root, { ...manifestDescriptor, size: resolveResult.manifest.bytes.length, annotations: { [`${CACHE_ANNOTATION_PREFIX}request`]: request } }, options.beforeIndexRename);
455
548
  alreadyInstalled = lostRace;
549
+ await storePending(rctx, finalDir, pending);
456
550
  }
457
551
  }
552
+ if (usedInstalled && revocationOn()) {
553
+ const used = resolvedFromRecord(record, platform, request, recordDir, resolveResult.repositoryRoot, true, []);
554
+ await checkInstalledOnce(rctx, used, warnings);
555
+ }
458
556
  if (options.lockWrite === true) {
459
- await writeLockEntry(request, platform, record, options, resolveResult.repositoryRoot, resolveResult.indexBytes, baseReqOptions);
557
+ warnings = [
558
+ ...warnings,
559
+ ...(await writeLockEntry(request, platform, record, options, resolveResult.repositoryRoot, resolveResult.indexBytes, baseReqOptions, rctx)),
560
+ ];
460
561
  }
461
562
  if (options.update === true) {
462
563
  // `update` re-resolves every locked request and rewrites the lock from
463
564
  // scratch (guide §6: it never merges a stale entry with a fresh one), in
464
565
  // addition to resolving the request above.
465
- await updateLock(options, bases, baseReqOptions, trustedKeys);
566
+ warnings = [...warnings, ...(await updateLock(options, bases, baseReqOptions, trustedKeys, rctx))];
466
567
  }
467
568
  return resolvedFromRecord(record, platform, request, recordDir, resolveResult.repositoryRoot, alreadyInstalled, warnings);
468
569
  }
570
+ /**
571
+ * The trust step of a fetch for one platform manifest (guide §4), exactly as
572
+ * `ensure` takes it before anything is downloaded: a signature referrer of the
573
+ * manifest verified under the trust list, its statement checked against the
574
+ * manifest's layer, the platform and `request`; no signature that verifies is
575
+ * `ArtifactUntrustedError` unless allow-unsigned is honored, when the config
576
+ * blob's metadata stands in with a warning. A signed build is then checked by
577
+ * the dev channel's ahead-of-registry rule (public issue #578: the alias for
578
+ * its own fingerprint answered 404 on every base, and the tag's build is
579
+ * signed for another), refused from the signed statement, before any layer is
580
+ * requested. `chtypes resolve` takes the same step for every platform.
581
+ */
582
+ export async function verifyManifestTrust(repositoryRoot, manifest, aliasAbsent, platform, request, options, baseReqOptions) {
583
+ const info = platformInfo(platform);
584
+ const listing = await readListing([repositoryRoot], manifest.digest, { ...baseReqOptions, maxBytes: 0 });
585
+ const trust = await verifyAnyReferrerBundle(repositoryRoot, manifest.digest, MEDIA_TYPE_BUNDLE, defaultTrustedKeys(options), (statement) => checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, manifest.layer.digest, {
586
+ os: info.os,
587
+ arch: info.architecture,
588
+ requestedSpelling: request,
589
+ }), { ...baseReqOptions, maxBytes: 0 }, listing.signatures);
590
+ if (trust === undefined) {
591
+ if (!allowUnsigned(options)) {
592
+ throw new ArtifactUntrustedError(activeChannel().overridable
593
+ ? `chtypes: no referrer of ${manifest.digest} verified under a trusted key (CHTYPES_ALLOW_UNSIGNED=1 to proceed anyway)`
594
+ : `chtypes: no referrer of ${manifest.digest} verified under the staging key ${activeChannel().keys.map((k) => k.keyid).join(', ')}`);
595
+ }
596
+ const predicate = await unsignedPredicateFallback(repositoryRoot, manifest, request, info, baseReqOptions);
597
+ return { trust, predicate, signedBy: '', warnings: ['chtypes: proceeding with an unsigned artifact (CHTYPES_ALLOW_UNSIGNED)'], listing };
598
+ }
599
+ const predicate = trust.statement.predicate;
600
+ await aheadOfRegistry(aliasAbsent, predicate, () => cachedOwnBuild(options, platform, request));
601
+ return { trust, predicate, signedBy: trust.signedBy, warnings: [], listing };
602
+ }
469
603
  /**
470
604
  * `CHTYPES_ALLOW_UNSIGNED`'s fallback metadata source: the tarball's own
471
605
  * `manifest.json`, which is also the OCI **config** blob and (per
@@ -510,12 +644,18 @@ async function unsignedPredicateFallback(repositoryRoot, manifest, request, info
510
644
  library_bytes: Number(asString(field(json, 'library_bytes'))) || 0,
511
645
  };
512
646
  }
513
- catch {
647
+ catch (err) {
648
+ // A 410 (guide §2) is the answer, never a fallback.
649
+ if (isGone(err))
650
+ throw err;
514
651
  return fallback;
515
652
  }
516
653
  }
517
- async function ensureFrozen(request, platform, options) {
518
- const root = cacheRoot(options.cacheDir);
654
+ /**
655
+ * The lock's pin for `request`/`platform`: no lock file, or a lock with no
656
+ * entry for them, is `CHTYPES_ARTIFACT_PINNED`.
657
+ */
658
+ async function lockedPin(request, platform, options) {
519
659
  const lockPath = options.lockPath ?? path.join(process.cwd(), 'chtypes.lock');
520
660
  const lock = await readLock(lockPath);
521
661
  if (lock === undefined) {
@@ -525,6 +665,60 @@ async function ensureFrozen(request, platform, options) {
525
665
  if (pin === undefined) {
526
666
  throw new ArtifactPinnedError(`chtypes: ${lockPath} names no entry for ${request}/${platform}; re-lock with \`fetch --lock\``);
527
667
  }
668
+ return pin;
669
+ }
670
+ /**
671
+ * The installed build a lock pin names, with no network: an unpacked
672
+ * directory named by the pinned manifest digest, in the cache or a system
673
+ * directory, whose `verified.json` is for this platform and records exactly
674
+ * the pinned manifest, layer and bundle digests (public issues #414, #487).
675
+ * `undefined` when no installed build matches.
676
+ */
677
+ async function installedByPin(request, platform, options, pin) {
678
+ return (await lookupByPin(request, platform, options, pin)).hit;
679
+ }
680
+ /** `installedByPin`, also saying whether a record exists under the pinned manifest but disagrees with the pin. */
681
+ async function lookupByPin(request, platform, options, pin) {
682
+ let mismatch = false;
683
+ const warnings = await probeRoots(searchRoots(options).map((r) => r.root), strictMode(options.strictCache));
684
+ for (const r of searchRoots(options)) {
685
+ const dir = unpackedDir(r.root, hexOfDigest(pin.manifest));
686
+ const record = await readVerifiedRecord(dir);
687
+ if (record === undefined)
688
+ continue;
689
+ if (record.platform === platform &&
690
+ record.manifestDigest === pin.manifest &&
691
+ record.layerDigest === pin.layer &&
692
+ record.bundleDigest === pin.bundle) {
693
+ return { hit: resolvedFromRecord(record, platform, request, dir, r.source, true, warnings), mismatch: false };
694
+ }
695
+ mismatch = true;
696
+ }
697
+ return { hit: undefined, mismatch };
698
+ }
699
+ /** `--frozen --offline`: verify the installed build against the lock, with zero network. No lock entry, or an installed record that does not match the pin, is `CHTYPES_ARTIFACT_PINNED`; a pinned build that is not installed is `CHTYPES_ARTIFACT_MISSING`. */
700
+ async function resolveFrozenOffline(request, platform, options) {
701
+ const pin = await lockedPin(request, platform, options);
702
+ const { hit, mismatch } = await lookupByPin(request, platform, options, pin);
703
+ if (hit === undefined) {
704
+ if (mismatch) {
705
+ throw new ArtifactPinnedError(`chtypes: --frozen --offline: the installed build for ${request} (${platform}) does not match the lock's pin ${pin.manifest}`);
706
+ }
707
+ throw new ArtifactMissingError(withNotes(`chtypes: --frozen --offline: the build the lock pins (${pin.manifest}) is not installed for ${request} (${platform})`, await missingNotes(options)));
708
+ }
709
+ return hit;
710
+ }
711
+ async function ensureFrozen(request, platform, options) {
712
+ const root = cacheRoot(options.cacheDir);
713
+ const pin = await lockedPin(request, platform, options);
714
+ // A build already installed under exactly the pinned digests is used as it
715
+ // is: zero requests (guide §6; public issues #414 and #487).
716
+ const installed = await installedByPin(request, platform, options, pin);
717
+ const rctx = revocationContext(options);
718
+ // Under generation 2, the one listing a warm --frozen fetch makes (guide §6,
719
+ // "Build-level revocation").
720
+ if (installed !== undefined)
721
+ return withWarnings(installed, await checkFrozenInstalled(rctx, installed));
528
722
  const bases = resolveBases(options);
529
723
  const baseReqOptions = requestOptionsFor(options, bases);
530
724
  const trustedKeys = defaultTrustedKeys(options);
@@ -540,13 +734,17 @@ async function ensureFrozen(request, platform, options) {
540
734
  const finalDir = unpackedDir(root, manifestHex);
541
735
  const existing = await readVerifiedRecord(finalDir);
542
736
  if (existing !== undefined) {
543
- return resolvedFromRecord(existing, platform, request, finalDir, 'cache', true, []);
737
+ const usedFrozen = resolvedFromRecord(existing, platform, request, finalDir, 'cache', true, []);
738
+ return withWarnings(usedFrozen, await checkFrozenInstalled(rctx, usedFrozen));
544
739
  }
740
+ // The bundle, then (generation 2) the one revocation listing, and only then the
741
+ // layer, so a revoked pin is never downloaded.
545
742
  const bundleBytes = await fetchBlobBytesByDigest(bases, { mediaType: MEDIA_TYPE_BUNDLE, digest: pin.bundle }, { ...baseReqOptions, maxBytes: 0 });
546
743
  const verified = verifyBundleSignature(bundleBytes, trustedKeys);
547
744
  let predicate;
548
745
  let signedBy;
549
746
  let warnings = [];
747
+ let pending;
550
748
  if (verified === undefined) {
551
749
  if (!allowUnsigned(options)) {
552
750
  throw new ArtifactUntrustedError(`chtypes: the locked bundle ${pin.bundle} does not verify under a trusted key`);
@@ -556,12 +754,19 @@ async function ensureFrozen(request, platform, options) {
556
754
  warnings = ['chtypes: proceeding with an unsigned artifact (CHTYPES_ALLOW_UNSIGNED)'];
557
755
  }
558
756
  else {
559
- predicate = checkArtifactStatement(parseStatement(verified.payload), PREDICATE_TYPE_ARTIFACT, manifest.layer.digest, {
757
+ const statement = parseStatement(verified.payload);
758
+ predicate = checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, manifest.layer.digest, {
560
759
  os: info.os,
561
760
  arch: info.architecture,
562
761
  requestedSpelling: request,
563
762
  });
564
763
  signedBy = verified.signedBy;
764
+ if (revocationOn()) {
765
+ const sub = subjectOfStatement(statement, pin.manifest, manifest.layer.digest, platform, request);
766
+ const out = await checkListing(rctx, sub, false);
767
+ await settle(rctx, sub, out, false, warnings);
768
+ pending = out.verified;
769
+ }
565
770
  }
566
771
  const staging = await freshStagingDir(root);
567
772
  const tempLayerPath = path.join(root, `.tmp-layer-${process.pid}-${randomBytes(6).toString('hex')}`);
@@ -586,6 +791,7 @@ async function ensureFrozen(request, platform, options) {
586
791
  });
587
792
  await writeVerifiedRecord(staging, record);
588
793
  const lostRace = await commitStaging(staging, finalDir);
794
+ await storePending(rctx, finalDir, pending);
589
795
  return resolvedFromRecord(record, platform, request, finalDir, bases[0], lostRace, warnings);
590
796
  }
591
797
  catch (err) {
@@ -602,24 +808,32 @@ async function ensureFrozen(request, platform, options) {
602
808
  * alone — never a merge with a stale entry (guide §6). A request with no
603
809
  * platform that verifies is dropped rather than carried over stale.
604
810
  */
605
- async function updateLock(options, bases, baseReqOptions, trustedKeys) {
811
+ async function updateLock(options, bases, baseReqOptions, trustedKeys, rctx) {
606
812
  const lockPath = options.lockPath ?? path.join(process.cwd(), 'chtypes.lock');
607
813
  const existing = await readLock(lockPath);
608
814
  if (existing === undefined)
609
815
  throw new ArtifactPinnedError(`chtypes: update with no lock file at ${lockPath}`);
610
816
  let fresh = emptyLock();
817
+ const warnings = [];
611
818
  for (const [spelling, byPlatform] of Object.entries(existing.requests)) {
612
819
  for (const key of Object.keys(byPlatform)) {
613
820
  const platformKey = key;
614
821
  const info = platformInfo(platformKey);
615
822
  const resolved = await resolveTag(bases, spelling, platformKey, { ...baseReqOptions, maxBytes: 0 });
823
+ const listing = await readListing([resolved.repositoryRoot], resolved.manifest.digest, { ...baseReqOptions, maxBytes: 0 });
616
824
  const trust = await verifyAnyReferrerBundle(resolved.repositoryRoot, resolved.manifest.digest, MEDIA_TYPE_BUNDLE, trustedKeys, (statement) => checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, resolved.manifest.layer.digest, {
617
825
  os: info.os,
618
826
  arch: info.architecture,
619
827
  requestedSpelling: spelling,
620
- }), { ...baseReqOptions, maxBytes: 0 });
828
+ }), { ...baseReqOptions, maxBytes: 0 }, listing.signatures);
621
829
  if (trust === undefined)
622
830
  continue;
831
+ // A revoked build on any platform the update would pin refuses it.
832
+ if (revocationOn()) {
833
+ const sub = subjectOfStatement(trust.statement, resolved.manifest.digest, resolved.manifest.layer.digest, platformKey, spelling);
834
+ const out = await checkCandidates(rctx, [resolved.repositoryRoot], listing.revocations, sub, false);
835
+ await settle(rctx, sub, out, false, warnings);
836
+ }
623
837
  const predicate = trust.statement.predicate;
624
838
  fresh = withPin(fresh, spelling, platformKey, {
625
839
  version: predicate.clickhouse_version,
@@ -631,8 +845,10 @@ async function updateLock(options, bases, baseReqOptions, trustedKeys) {
631
845
  }
632
846
  }
633
847
  await writeLock(lockPath, fresh);
848
+ return warnings;
634
849
  }
635
- async function writeLockEntry(request, platform, record, options, repositoryRoot, indexBytes, baseReqOptions) {
850
+ async function writeLockEntry(request, platform, record, options, repositoryRoot, indexBytes, baseReqOptions, rctx) {
851
+ const warnings = [];
636
852
  const lockPath = options.lockPath ?? path.join(process.cwd(), 'chtypes.lock');
637
853
  let lock = (await readLock(lockPath)) ?? emptyLock();
638
854
  lock = withPin(lock, request, platform, {
@@ -642,7 +858,7 @@ async function writeLockEntry(request, platform, record, options, repositoryRoot
642
858
  layer: record.layerDigest,
643
859
  bundle: record.bundleDigest ?? '',
644
860
  });
645
- if (options.lockAllPlatforms === true) {
861
+ if (options.lockAllPlatforms !== false) {
646
862
  const indexJson = parseJsonValue(indexBytes);
647
863
  const manifests = items(field(indexJson ?? undefined, 'manifests'));
648
864
  const trustedKeys = defaultTrustedKeys(options);
@@ -656,32 +872,50 @@ async function writeLockEntry(request, platform, record, options, repositoryRoot
656
872
  const digest = asString(field(m, 'digest'));
657
873
  if (digest === '')
658
874
  continue;
875
+ let otherManifest;
876
+ let listing;
877
+ let trust;
659
878
  try {
660
- const otherManifest = await fetchManifestByDigest([repositoryRoot], digest, { ...baseReqOptions, maxBytes: 0 });
879
+ otherManifest = await fetchManifestByDigest([repositoryRoot], digest, { ...baseReqOptions, maxBytes: 0 });
661
880
  const otherInfo = platformInfo(key);
662
- const trust = await verifyAnyReferrerBundle(repositoryRoot, otherManifest.digest, MEDIA_TYPE_BUNDLE, trustedKeys, (statement) => checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, otherManifest.layer.digest, {
881
+ const layerDigest = otherManifest.layer.digest;
882
+ listing = await readListing([repositoryRoot], otherManifest.digest, { ...baseReqOptions, maxBytes: 0 });
883
+ trust = await verifyAnyReferrerBundle(repositoryRoot, otherManifest.digest, MEDIA_TYPE_BUNDLE, trustedKeys, (statement) => checkArtifactStatement(statement, PREDICATE_TYPE_ARTIFACT, layerDigest, {
663
884
  os: otherInfo.os,
664
885
  arch: otherInfo.architecture,
665
886
  requestedSpelling: request,
666
- }), { ...baseReqOptions, maxBytes: 0 });
667
- if (trust === undefined)
668
- continue;
669
- const otherPredicate = trust.statement.predicate;
670
- lock = withPin(lock, request, key, {
671
- version: otherPredicate.clickhouse_version,
672
- build: otherPredicate.build,
673
- manifest: otherManifest.digest,
674
- layer: otherManifest.layer.digest,
675
- bundle: trust.bundleDigest,
676
- });
887
+ }), { ...baseReqOptions, maxBytes: 0 }, listing.signatures);
677
888
  }
678
- catch {
889
+ catch (err) {
890
+ // A 410 (guide §2) fails the write: it is permanent, never a platform to
891
+ // leave out.
892
+ if (isGone(err))
893
+ throw err;
679
894
  // Best-effort: a platform whose bundle cannot be fetched/verified is
680
895
  // left out of the lock rather than failing the whole write.
896
+ continue;
897
+ }
898
+ if (trust === undefined || otherManifest === undefined || listing === undefined)
899
+ continue;
900
+ // A revoked build on any platform the lock would pin refuses the write,
901
+ // whatever the override says (`revocationContext` drops it for a lock).
902
+ if (revocationOn()) {
903
+ const sub = subjectOfStatement(trust.statement, otherManifest.digest, otherManifest.layer.digest, key, request);
904
+ const out = await checkCandidates(rctx, [repositoryRoot], listing.revocations, sub, false);
905
+ await settle(rctx, sub, out, false, warnings);
681
906
  }
907
+ const otherPredicate = trust.statement.predicate;
908
+ lock = withPin(lock, request, key, {
909
+ version: otherPredicate.clickhouse_version,
910
+ build: otherPredicate.build,
911
+ manifest: otherManifest.digest,
912
+ layer: otherManifest.layer.digest,
913
+ bundle: trust.bundleDigest,
914
+ });
682
915
  }
683
916
  }
684
917
  await writeLock(lockPath, lock);
918
+ return warnings;
685
919
  }
686
920
  function isLikelyPlatformKey(key) {
687
921
  return key === 'linux-amd64' || key === 'linux-arm64' || key === 'darwin-arm64';
@@ -779,7 +1013,8 @@ async function fetchGoldens(repository, subject, root, trustedKeys, allowUnsigne
779
1013
  blob = await fetchBlobBytesByDigest([repository], manifest.layer, reqOptions);
780
1014
  }
781
1015
  catch (err) {
782
- if (!(err instanceof FetchV1Error))
1016
+ // A 410 (guide §2) is the answer, never a candidate to pass over.
1017
+ if (!(err instanceof FetchV1Error) || isGone(err))
783
1018
  throw err;
784
1019
  note(err);
785
1020
  continue;
@@ -795,7 +1030,9 @@ async function fetchGoldens(repository, subject, root, trustedKeys, allowUnsigne
795
1030
  bundleDigest = bundleManifest.layer.digest;
796
1031
  bundleBytes = await fetchBlobBytesByDigest([repository], bundleManifest.layer, reqOptions);
797
1032
  }
798
- catch {
1033
+ catch (err) {
1034
+ if (isGone(err))
1035
+ throw err;
799
1036
  continue;
800
1037
  }
801
1038
  const signed = verifyBundleSignature(bundleBytes, trustedKeys);
@@ -848,6 +1085,8 @@ async function fetchGoldens(repository, subject, root, trustedKeys, allowUnsigne
848
1085
  return { path: dest, statement: chosen.predicate, digests: { manifest: chosen.manifest, layer: chosen.blob } };
849
1086
  }
850
1087
  // ---------------------------------------------------------------------- listTags
1088
+ /** The published lines: exactly two numeric parts, no leading zeros (Go's `lineTag`). */
1089
+ const LINE_SPELLING = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
851
1090
  /** Compares two version spellings component by component, numerically. */
852
1091
  function compareSpellings(a, b) {
853
1092
  const pa = a.split('.').map(Number);
@@ -860,18 +1099,18 @@ function compareSpellings(a, b) {
860
1099
  return 0;
861
1100
  }
862
1101
  /**
863
- * The version spellings a repository publishes: `GET tags/list` from the first
864
- * base that has the repository, every tag that is not a version spelling
865
- * (`SPELLING_REGEX`: the `sha256-<hex>` referrers fallback tags, symbolic tags)
866
- * dropped, the rest in ascending numeric order. A tag names a version, not a
867
- * platform: whether a platform is offered is that tag's index, which only a
868
- * resolve reads. A next page is followed through the `Link` header.
1102
+ * The lines a repository publishes: `GET tags/list` from the first base that
1103
+ * has the repository, every tag that is not a two-part line (three- and
1104
+ * four-part versions, aliases, the `sha256-<hex>` referrers fallback tags,
1105
+ * symbolic tags) dropped, the rest in ascending numeric order. A tag names a
1106
+ * line, not a platform: whether a platform is offered is that tag's index,
1107
+ * which only a resolve reads. A next page is followed through the `Link`
1108
+ * header.
869
1109
  */
870
1110
  export async function listTags(options = {}) {
871
1111
  noteIgnoredOverrides(options);
872
1112
  const bases = resolveBases(options);
873
1113
  const reqOptions = { ...requestOptionsFor(options, bases), maxBytes: TAGS_LIST_MAX_BYTES };
874
- const spelling = new RegExp(SPELLING_REGEX);
875
1114
  let lastUnreachable;
876
1115
  for (let i = 0; i < bases.length; i++) {
877
1116
  const base = bases[i];
@@ -904,7 +1143,7 @@ export async function listTags(options = {}) {
904
1143
  found = true;
905
1144
  for (const t of items(field(json, 'tags'))) {
906
1145
  const tag = asString(t);
907
- if (spelling.test(tag))
1146
+ if (LINE_SPELLING.test(tag))
908
1147
  tags.add(tag);
909
1148
  }
910
1149
  const link = res.headers['link'];