aontu 0.63.0 → 0.65.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 (101) hide show
  1. package/README.md +8 -8
  2. package/dist/agentsmd.js +3 -3
  3. package/dist/agentsmd.js.map +1 -1
  4. package/dist/aontu.d.ts +3 -3
  5. package/dist/aontu.js +4 -6
  6. package/dist/aontu.js.map +1 -1
  7. package/dist/aontumodel.js +6 -12
  8. package/dist/aontumodel.js.map +1 -1
  9. package/dist/casing.d.ts +5 -0
  10. package/dist/casing.js +90 -0
  11. package/dist/casing.js.map +1 -0
  12. package/dist/cli.d.ts +16 -3
  13. package/dist/cli.js +653 -455
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compat.d.ts +7 -0
  16. package/dist/compat.js +113 -0
  17. package/dist/compat.js.map +1 -0
  18. package/dist/helpdoc.js +5 -5
  19. package/dist/helpdoc.js.map +1 -1
  20. package/dist/hints.js +14 -5
  21. package/dist/hints.js.map +1 -1
  22. package/dist/lang.d.ts +1 -0
  23. package/dist/lang.js +8 -0
  24. package/dist/lang.js.map +1 -1
  25. package/dist/lsp.d.ts +1 -1
  26. package/dist/mcp.js +0 -36
  27. package/dist/mcp.js.map +1 -1
  28. package/dist/mod.d.ts +18 -3
  29. package/dist/mod.js +117 -46
  30. package/dist/mod.js.map +1 -1
  31. package/dist/pkg-net.d.ts +227 -0
  32. package/dist/pkg-net.js +1598 -0
  33. package/dist/pkg-net.js.map +1 -0
  34. package/dist/pkg-zip.d.ts +8 -0
  35. package/dist/pkg-zip.js +196 -0
  36. package/dist/pkg-zip.js.map +1 -0
  37. package/dist/pkg.d.ts +159 -0
  38. package/dist/pkg.js +767 -0
  39. package/dist/pkg.js.map +1 -0
  40. package/dist/profile.d.ts +9 -0
  41. package/dist/profile.js +28 -0
  42. package/dist/profile.js.map +1 -0
  43. package/dist/report-sarif.js +1 -1
  44. package/dist/report-sarif.js.map +1 -1
  45. package/dist/subsume.d.ts +3 -1
  46. package/dist/subsume.js +2 -0
  47. package/dist/subsume.js.map +1 -1
  48. package/dist/trace.d.ts +21 -0
  49. package/dist/trace.js +107 -0
  50. package/dist/trace.js.map +1 -0
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/dist/unify.js +1 -6
  53. package/dist/unify.js.map +1 -1
  54. package/dist/val/CmpFuncVal.d.ts +2 -0
  55. package/dist/val/CmpFuncVal.js +47 -13
  56. package/dist/val/CmpFuncVal.js.map +1 -1
  57. package/dist/val/NomFuncVal.js +11 -13
  58. package/dist/val/NomFuncVal.js.map +1 -1
  59. package/dist/val/ReferFuncVal.js +3 -0
  60. package/dist/val/ReferFuncVal.js.map +1 -1
  61. package/package.json +1 -1
  62. package/skill/SKILL.md +3 -3
  63. package/skill/error-codes.md +4 -4
  64. package/skill/examples.md +4 -4
  65. package/skill/grammar-card.md +1 -1
  66. package/skill/init/check.sh +2 -2
  67. package/skill/tasks.md +14 -12
  68. package/src/agentsmd.ts +3 -3
  69. package/src/aontu.ts +3 -8
  70. package/src/aontumodel.ts +6 -12
  71. package/src/casing.ts +95 -0
  72. package/src/cli.ts +758 -495
  73. package/src/compat.ts +148 -0
  74. package/src/helpdoc.ts +5 -5
  75. package/src/hints.ts +14 -7
  76. package/src/lang.ts +9 -1
  77. package/src/mcp.ts +0 -39
  78. package/src/mod.ts +142 -51
  79. package/src/pkg-net.ts +1945 -0
  80. package/src/pkg-zip.ts +228 -0
  81. package/src/pkg.ts +1018 -0
  82. package/src/profile.ts +42 -0
  83. package/src/report-sarif.ts +1 -1
  84. package/src/subsume.ts +2 -2
  85. package/src/trace.ts +157 -0
  86. package/src/unify.ts +1 -6
  87. package/src/val/CmpFuncVal.ts +66 -20
  88. package/src/val/NomFuncVal.ts +3 -5
  89. package/src/val/ReferFuncVal.ts +4 -0
  90. package/dist/lower.d.ts +0 -23
  91. package/dist/lower.js +0 -528
  92. package/dist/lower.js.map +0 -1
  93. package/dist/mod-tool.d.ts +0 -58
  94. package/dist/mod-tool.js +0 -403
  95. package/dist/mod-tool.js.map +0 -1
  96. package/dist/render.d.ts +0 -53
  97. package/dist/render.js +0 -432
  98. package/dist/render.js.map +0 -1
  99. package/src/lower.ts +0 -586
  100. package/src/mod-tool.ts +0 -561
  101. package/src/render.ts +0 -580
package/src/pkg-net.ts ADDED
@@ -0,0 +1,1945 @@
1
+ /* Copyright (c) 2025 Richard Rodger, MIT License */
2
+
3
+
4
+ // THE CLIENT HALF OF THE PACKAGE REPOSITORY (aontu-lang/system, spec/):
5
+ // acquisition, sync, publication and the local registry, over one
6
+ // injected transport. Every decision is above the seam; the adapter
7
+ // below it is the platform's fetch and nothing else.
8
+
9
+ import {
10
+ createPrivateKey, createPublicKey, sign as cryptoSign, verify as cryptoVerify,
11
+ randomBytes, generateKeyPairSync,
12
+ } from 'node:crypto'
13
+ import type { KeyObject } from 'node:crypto'
14
+ import {
15
+ readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync,
16
+ renameSync, statSync,
17
+ } from 'node:fs'
18
+ import { createServer } from 'node:http'
19
+ import type { Server, IncomingMessage, ServerResponse } from 'node:http'
20
+ import { join as pathJoin, dirname as pathDirname } from 'node:path'
21
+
22
+ import {
23
+ isAlias, moduleDir, escapeElem, cacheStoreDir,
24
+ cacheDownloadDir, cacheSeenDir, PKG_FILE, LOCK_FILE, META_DIR, VENDOR_DIR,
25
+ lockJson, MODULE_MAX_DEPTH,
26
+ } from './mod'
27
+ import {
28
+ declaredDeps, versionCompare, usableKey, readLock, lockText, packageSelf,
29
+ archiveOf, archiveAdmits, pkgManifest, manifestText, pkgVerify, pkgResolve,
30
+ vendorCopy, copyTree, writeLock, targetOf, relPathError, MANIFEST_SCHEMA, VERSION_RE, ARCHIVE_LIMITS,
31
+ } from './pkg'
32
+ import type {
33
+ PkgToolOptions, LockEntry, Dependency, PkgManifest, PkgManifestReport,
34
+ PkgMismatch, ArchiveFile,
35
+ } from './pkg'
36
+ import { unzipCanonical, sha256Hex, cmpBytes } from './pkg-zip'
37
+
38
+
39
+ export type HttpResponse = { status: number, body: Uint8Array }
40
+
41
+ export type PublishParts = {
42
+ manifest: Uint8Array
43
+ proof: Uint8Array
44
+ archive: Uint8Array
45
+ }
46
+
47
+ // The seam. `get` reads one object; `post` sends one publish.
48
+ export type PkgHttp = {
49
+ get: (url: string) => Promise<HttpResponse>
50
+ post: (url: string, parts: PublishParts, token: string) => Promise<HttpResponse>
51
+ }
52
+
53
+
54
+ export const DEFAULT_BASE = 'https://pkg.aontu.dev'
55
+ export const DEFAULT_WRITE = 'https://publish.aontu.dev'
56
+ export const PUBLISH_PATH = '/v1/publish'
57
+
58
+ export const COOLDOWN_HOURS = 72
59
+ export const CLOSURE_MAX = 1024
60
+
61
+ // The closure bounds, as a record so a test can lower them.
62
+ export const LIMITS = { depth: MODULE_MAX_DEPTH, closure: CLOSURE_MAX }
63
+ export const ARCHIVE_MAX_BYTES = ARCHIVE_LIMITS.bytes
64
+ export const ARCHIVE_MAX_UNPACKED = ARCHIVE_LIMITS.unpacked
65
+ export const ARCHIVE_MAX_FILES = ARCHIVE_LIMITS.files
66
+ export const ARCHIVE_MAX_FILE_BYTES = ARCHIVE_LIMITS.fileBytes
67
+
68
+ export const SIGNATURE_ENCODING = 'aontu-signature/v1'
69
+
70
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/
71
+ const CANON_RE = /^aon1-[A-Za-z0-9_-]{43}$/
72
+ const KEY_ID_RE = /^ed25519:[A-Za-z0-9_-]{43}$/
73
+ const SIG_RE = /^[A-Za-z0-9_-]{86}$/
74
+ const PATTERN_RE =
75
+ /^\*$|^[a-z0-9.-]+$|^[a-z0-9.-]+\/[A-Za-z0-9._/-]+$|^[a-z0-9.-]+\/\*$|^[a-z0-9.-]+\/[A-Za-z0-9._/-]+\/\*$/
76
+
77
+
78
+ export type TrustEntry = { signer: string, inclusion: 'required' | 'none' }
79
+
80
+ export type RepoConfig = {
81
+ base: string[]
82
+ write: string
83
+ private: string[]
84
+ privateBase: string[]
85
+ trust: Record<string, TrustEntry>
86
+ }
87
+
88
+ export type PkgEvent = { code: string, message: string }
89
+
90
+ export type PkgRefusalReport = { code: string, message: string, pkg?: string }
91
+
92
+
93
+ export class PkgRefusal extends Error {
94
+ code: string
95
+ pkg?: string
96
+ constructor(code: string, message: string, pkg?: string) {
97
+ super(message)
98
+ this.code = code
99
+ if (null != pkg) {
100
+ this.pkg = pkg
101
+ }
102
+ }
103
+ }
104
+
105
+ function refuse(code: string, message: string, pkg?: string): never {
106
+ throw new PkgRefusal(code, message, pkg)
107
+ }
108
+
109
+
110
+ export function isLoopback(url: string): boolean {
111
+ let u: URL
112
+ try {
113
+ u = new URL(url)
114
+ }
115
+ catch {
116
+ return false
117
+ }
118
+ return 'http:' === u.protocol &&
119
+ ('127.0.0.1' === u.hostname || 'localhost' === u.hostname || '[::1]' === u.hostname)
120
+ }
121
+
122
+ // A base is https, or http on a loopback host (ADR-039 part 10).
123
+ export function baseAdmitted(url: string): boolean {
124
+ let u: URL
125
+ try {
126
+ u = new URL(url)
127
+ }
128
+ catch {
129
+ return false
130
+ }
131
+ return 'https:' === u.protocol || isLoopback(url)
132
+ }
133
+
134
+
135
+ export type RepoOverrides = { base?: string[], write?: string }
136
+
137
+ // The project's trust configuration: `repo` in its package file, with
138
+ // the defaults where it is silent, and the command line over both.
139
+ export function repoConfig(root: string, options: PkgToolOptions,
140
+ overrides: RepoOverrides = {}): RepoConfig {
141
+ const file = pathJoin(root, PKG_FILE)
142
+ const gen: any = existsSync(file) ?
143
+ options.eval(readFileSync(file, 'utf8'), file).gen : undefined
144
+ const repo: any = gen?.repo ?? {}
145
+ const strs = (v: any): string[] =>
146
+ Array.isArray(v) ? v.filter((s: any) => 'string' === typeof s) : []
147
+
148
+ const trust: Record<string, TrustEntry> = {
149
+ '*': { signer: 'forge', inclusion: 'required' },
150
+ }
151
+ const declared = repo.trust
152
+ if (null != declared && 'object' === typeof declared) {
153
+ for (const pattern of Object.keys(declared)) {
154
+ const e = declared[pattern]
155
+ if (!PATTERN_RE.test(pattern) || null == e || 'object' !== typeof e) {
156
+ refuse('config_invalid', 'repo.trust: ' + pattern + ' is not a pattern')
157
+ }
158
+ const signer = 'string' === typeof e.signer ? e.signer : ''
159
+ if ('forge' !== signer && !KEY_ID_RE.test(signer)) {
160
+ refuse('config_invalid',
161
+ 'repo.trust: ' + pattern + ' names no signer (forge, or ed25519:<key>)')
162
+ }
163
+ trust[pattern] = {
164
+ signer, inclusion: 'none' === e.inclusion ? 'none' : 'required',
165
+ }
166
+ }
167
+ }
168
+
169
+ const priv = strs(repo.private)
170
+ for (const p of priv) {
171
+ if (!PATTERN_RE.test(p)) {
172
+ refuse('config_invalid', 'repo.private: ' + p + ' is not a pattern')
173
+ }
174
+ }
175
+ // A package the project itself marks private is on the list by that
176
+ // fact alone (spec private.prefix_list.derived).
177
+ const self = packageSelf(root, options)
178
+ if ('' !== self.path && 'private' === self.publish && !priv.includes(self.path)) {
179
+ priv.push(self.path)
180
+ }
181
+
182
+ const base = overrides.base ?? (0 < strs(repo.base).length ? strs(repo.base) : [DEFAULT_BASE])
183
+ const write = overrides.write ?? ('string' === typeof repo.write ? repo.write : DEFAULT_WRITE)
184
+ for (const b of [...base, write, ...strs(repo.private_base)]) {
185
+ if (!baseAdmitted(b)) {
186
+ refuse('base_not_https', 'repository base is not https: ' + b)
187
+ }
188
+ }
189
+
190
+ return { base, write, private: priv, privateBase: strs(repo.private_base), trust }
191
+ }
192
+
193
+
194
+ // A pattern is a path, a path with `/*` beneath it, or `*` alone.
195
+ export function patternMatches(pattern: string, pkg: string): boolean {
196
+ if ('*' === pattern) {
197
+ return true
198
+ }
199
+ if (pattern.endsWith('/*')) {
200
+ const prefix = pattern.slice(0, -2)
201
+ return pkg === prefix || pkg.startsWith(prefix + '/')
202
+ }
203
+ return pattern === pkg
204
+ }
205
+
206
+ // The most specific entry: an exact path, then the longest prefix,
207
+ // then the default.
208
+ export function trustEntryFor(config: RepoConfig, pkg: string): TrustEntry {
209
+ let best = '*'
210
+ for (const pattern of Object.keys(config.trust)) {
211
+ if (!patternMatches(pattern, pkg)) {
212
+ continue
213
+ }
214
+ if (pattern === pkg || (best !== pkg && pattern.length > best.length)) {
215
+ best = pattern
216
+ }
217
+ }
218
+ return config.trust[best]
219
+ }
220
+
221
+ export function isPrivateName(config: RepoConfig, pkg: string): boolean {
222
+ return config.private.some((p) => patternMatches(p, pkg))
223
+ }
224
+
225
+
226
+ // The read-path layout (spec layout.objects).
227
+ export function pkgUrlPath(pkg: string): string {
228
+ return pkg.split('/').map(escapeElem).join('/')
229
+ }
230
+
231
+ export function objectPath(kind: string, pkg: string, version?: string): string {
232
+ const p = '/pkg/' + pkgUrlPath(pkg)
233
+ switch (kind) {
234
+ case 'list': return p + '/@v/list'
235
+ case 'latest': return p + '/@latest'
236
+ case 'archive': return p + '/@v/' + version + '.zip'
237
+ case 'manifest': return p + '/@v/' + version + '.manifest'
238
+ case 'signature': return p + '/@v/' + version + '.sig'
239
+ case 'sigstore': return p + '/@v/' + version + '.sigstore.json'
240
+ case 'advisory': return '/advisory/' + pkgUrlPath(pkg) + '.aon'
241
+ default: return '/tombstone/' + pkgUrlPath(pkg) + '/@v/' + version + '.aon'
242
+ }
243
+ }
244
+
245
+
246
+ export function timestamp(d: Date): string {
247
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
248
+ }
249
+
250
+
251
+ const utf8 = (s: string): Uint8Array => new Uint8Array(Buffer.from(s, 'utf8'))
252
+ const text = (b: Uint8Array): string => Buffer.from(b).toString('utf8')
253
+
254
+ function parseDoc(b: Uint8Array): any {
255
+ try {
256
+ return JSON.parse(lockJson(text(b)))
257
+ }
258
+ catch {
259
+ return undefined
260
+ }
261
+ }
262
+
263
+ // One canonical line, the form every stored object takes.
264
+ function canonLine(obj: any, options: PkgToolOptions, name: string): Uint8Array {
265
+ return utf8(options.eval(JSON.stringify(obj), name).canon + '\n')
266
+ }
267
+
268
+
269
+ // THE KEY PROVIDER (ADR-024 part 4): an Ed25519 key signs the manifest's
270
+ // digest, domain-separated by the encoding name.
271
+ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
272
+
273
+ function signedBytes(over: string): Buffer {
274
+ return Buffer.from(SIGNATURE_ENCODING + '\n' + over + '\n', 'utf8')
275
+ }
276
+
277
+ export function keyIdOf(publicKeyDer: Uint8Array): string {
278
+ const raw = Buffer.from(publicKeyDer).subarray(publicKeyDer.length - 32)
279
+ return 'ed25519:' + raw.toString('base64url')
280
+ }
281
+
282
+ // The signing key is an Ed25519 private key and nothing else: another
283
+ // kind signs, and every consumer refuses the proof, so it is refused
284
+ // here first.
285
+ function signingKey(pem: string): KeyObject {
286
+ let priv: KeyObject
287
+ try {
288
+ priv = createPrivateKey(pem)
289
+ }
290
+ catch {
291
+ refuse('key_invalid', 'the key file is not a PEM private key', '')
292
+ }
293
+ if ('ed25519' !== priv.asymmetricKeyType) {
294
+ refuse('key_invalid', 'the key is ' + priv.asymmetricKeyType + ', not ed25519', '')
295
+ }
296
+ return priv
297
+ }
298
+
299
+ export function keyIdFromPem(pem: string): string {
300
+ const der = createPublicKey(signingKey(pem)).export({ format: 'der', type: 'spki' })
301
+ return keyIdOf(new Uint8Array(der))
302
+ }
303
+
304
+ // `aontu pkg keygen`: a new signing key, written once. The answer is
305
+ // the signer id a consumer names, or the reason nothing was written.
306
+ export function keygen(file: string): { signer?: string, refused?: string } {
307
+ if (existsSync(file)) {
308
+ return { refused: file + ' exists; a key is written once' }
309
+ }
310
+ const pem = generateKeyPairSync('ed25519').privateKey
311
+ .export({ format: 'pem', type: 'pkcs8' }) as string
312
+ mkdirSync(pathDirname(file), { recursive: true })
313
+ writeFileSync(file, pem, { mode: 0o600 })
314
+ return { signer: keyIdFromPem(pem) }
315
+ }
316
+
317
+ export type KeyProof = {
318
+ kind: 'key'
319
+ encoding: typeof SIGNATURE_ENCODING
320
+ over: string
321
+ signer: string
322
+ signature: string
323
+ }
324
+
325
+ export function signDigest(pem: string, over: string): KeyProof {
326
+ const priv = signingKey(pem)
327
+ const sig = cryptoSign(null, signedBytes(over), priv)
328
+ return {
329
+ kind: 'key',
330
+ encoding: SIGNATURE_ENCODING,
331
+ over,
332
+ signer: keyIdFromPem(pem),
333
+ signature: Buffer.from(sig).toString('base64url'),
334
+ }
335
+ }
336
+
337
+ // The small-order points of the curve, by y with the sign bit cleared:
338
+ // a signature under one verifies for any message. The last entry is p,
339
+ // and every encoding at or above it is not canonical.
340
+ const SMALL_ORDER_Y = new Set([
341
+ '0000000000000000000000000000000000000000000000000000000000000000',
342
+ '0100000000000000000000000000000000000000000000000000000000000000',
343
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
344
+ 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
345
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
346
+ ])
347
+
348
+ export function smallOrderKey(raw: Uint8Array): boolean {
349
+ const y = Buffer.from(raw)
350
+ y[31] &= 0x7f
351
+ const hex = y.toString('hex')
352
+ return SMALL_ORDER_Y.has(hex) || (hex.endsWith('ff'.repeat(30) + '7f') && 0xed <= y[0])
353
+ }
354
+
355
+ // Base64url that decodes and re-encodes to itself: Buffer drops
356
+ // trailing bits silently, so a signature has one spelling here.
357
+ function canonicalBase64url(text: string, length: number): Buffer | undefined {
358
+ const bytes = Buffer.from(text, 'base64url')
359
+ return length === bytes.length && bytes.toString('base64url') === text ? bytes : undefined
360
+ }
361
+
362
+ export function verifyKeyProof(proof: any, over: string, signer: string): string | undefined {
363
+ if (null == proof || 'key' !== proof.kind || SIGNATURE_ENCODING !== proof.encoding ||
364
+ 'string' !== typeof proof.signature || !KEY_ID_RE.test(proof.signer ?? '')) {
365
+ return 'the proof is not an aontu-signature/v1 key proof'
366
+ }
367
+ if (proof.over !== over) {
368
+ return 'the proof signs ' + proof.over + ', not this manifest'
369
+ }
370
+ if (proof.signer !== signer) {
371
+ return 'signed by ' + proof.signer + '; the trust entry accepts ' + signer
372
+ }
373
+ const raw = canonicalBase64url(proof.signer.slice('ed25519:'.length), 32)
374
+ const sig = SIG_RE.test(proof.signature) ? canonicalBase64url(proof.signature, 64) : undefined
375
+ if (undefined === raw || undefined === sig) {
376
+ return 'the proof carries a malformed key or signature'
377
+ }
378
+ if (smallOrderKey(raw)) {
379
+ return 'the signer is a key of small order'
380
+ }
381
+ const key = createPublicKey({
382
+ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: 'der', type: 'spki',
383
+ })
384
+ return cryptoVerify(null, signedBytes(over), key, sig) ?
385
+ undefined : 'the signature does not verify'
386
+ }
387
+
388
+
389
+ // A package path: domain-shaped, the element rules, never an alias.
390
+ export function packagePath(s: any): boolean {
391
+ return 'string' === typeof s && !isAlias(s) && usableKey(s)
392
+ }
393
+
394
+
395
+ // T.Manifest, checked field by field: a served object is input.
396
+ export function manifestError(m: any): string | undefined {
397
+ if (null == m || 'object' !== typeof m || MANIFEST_SCHEMA !== m.schema) {
398
+ return 'schema is not ' + MANIFEST_SCHEMA
399
+ }
400
+ if (!packagePath(m.package)) {
401
+ return 'package is not a package path'
402
+ }
403
+ if ('string' !== typeof m.version || !VERSION_RE.test(m.version)) {
404
+ return 'version is not MAJOR.MINOR.PATCH'
405
+ }
406
+ if ('public' !== m.publish && 'private' !== m.publish) {
407
+ return 'publish is not public or private'
408
+ }
409
+ const a = m.archive
410
+ if (null == a || 'zip' !== a.format || !DIGEST_RE.test(a.digest ?? '') ||
411
+ !Number.isInteger(a.size) || !Array.isArray(a.files) || 0 === a.files.length) {
412
+ return 'archive is not a zip with a digest, a size and files'
413
+ }
414
+ for (const f of a.files) {
415
+ if ('string' !== typeof f?.path || !DIGEST_RE.test(f.digest ?? '') ||
416
+ !Number.isInteger(f.size) || undefined !== relPathError(f.path)) {
417
+ return 'archive.files names a file without a path, a digest and a size'
418
+ }
419
+ }
420
+ if (!Array.isArray(m.modules) || 1 !== m.modules.length ||
421
+ m.modules[0]?.path !== m.package || 'string' !== typeof m.modules[0]?.main ||
422
+ !CANON_RE.test(m.modules[0]?.canon ?? '')) {
423
+ return 'modules is not the one module at the package path with an entry and a canon-hash'
424
+ }
425
+ if (undefined !== relPathError(m.modules[0].main) ||
426
+ !a.files.some((f: any) => f.path === m.modules[0].main)) {
427
+ return 'modules names an entry the archive does not hold'
428
+ }
429
+ if (null == m.deps || 'object' !== typeof m.deps || Array.isArray(m.deps)) {
430
+ return 'deps is not a map'
431
+ }
432
+ for (const k of Object.keys(m.deps)) {
433
+ const d = m.deps[k]
434
+ if ('string' !== typeof d?.v || !VERSION_RE.test(d.v) ||
435
+ (null != d.pkg && !packagePath(d.pkg))) {
436
+ return 'deps.' + k + ' is not a minimum version'
437
+ }
438
+ }
439
+ if ('string' !== typeof m.published) {
440
+ return 'published is not a timestamp'
441
+ }
442
+ if (null != m.moved && !packagePath(m.moved)) {
443
+ return 'moved is not a package path'
444
+ }
445
+ if (null != m.retract && (!Array.isArray(m.retract) ||
446
+ m.retract.some((v: any) => 'string' !== typeof v || !VERSION_RE.test(v)))) {
447
+ return 'retract is not a list of versions'
448
+ }
449
+ return undefined
450
+ }
451
+
452
+
453
+ // A path inside an archive: forward slashes, the element rules, never
454
+ // absolute and never escaping.
455
+ export { relPathError }
456
+
457
+
458
+ type Acquired = {
459
+ pkg: string
460
+ version: string
461
+ canon: string
462
+ archive: string
463
+ manifestDigest: string
464
+ deps: Record<string, Dependency>
465
+ dir: string
466
+ // The lock entries of the whole closure beneath it: what the module
467
+ // was evaluated against, so a dependant evaluates against the same.
468
+ closure: LockEntry[]
469
+ }
470
+
471
+ type AcquireCtx = {
472
+ options: PkgToolOptions
473
+ http: PkgHttp
474
+ config: RepoConfig
475
+ cache: string
476
+ now: () => Date
477
+ events: PkgEvent[]
478
+ fetched: string[]
479
+ acquired: Record<string, Acquired>
480
+ count: number
481
+ }
482
+
483
+ type VersionEntry = { version: string, seen: string }
484
+
485
+
486
+ async function getObject(ctx: AcquireCtx, bases: string[], path: string):
487
+ Promise<HttpResponse & { base: string }> {
488
+ let last: HttpResponse & { base: string } = { status: 0, body: new Uint8Array(), base: '' }
489
+ for (const base of bases) {
490
+ const r = await ctx.http.get(base.replace(/\/$/, '') + path)
491
+ if (200 === r.status) {
492
+ return { ...r, base }
493
+ }
494
+ last = { ...r, base }
495
+ }
496
+ return last
497
+ }
498
+
499
+
500
+ function basesFor(ctx: AcquireCtx, pkg: string): string[] {
501
+ if (!isPrivateName(ctx.config, pkg)) {
502
+ return ctx.config.base
503
+ }
504
+ if (0 === ctx.config.privateBase.length) {
505
+ refuse('private_name_public_path',
506
+ pkg + ' is on the private list and repo.private_base names no repository', pkg)
507
+ }
508
+ return ctx.config.privateBase
509
+ }
510
+
511
+
512
+ async function fetchList(ctx: AcquireCtx, bases: string[], pkg: string):
513
+ Promise<VersionEntry[]> {
514
+ const r = await getObject(ctx, bases, objectPath('list', pkg))
515
+ if (200 !== r.status) {
516
+ refuse('fetch_failed', 'no version list for ' + pkg +
517
+ (0 === r.status ? ' (no repository answered)' : ' (' + r.status + ' from ' + r.base + ')'),
518
+ pkg)
519
+ }
520
+ const doc = parseDoc(r.body)
521
+ if (doc?.package !== pkg || !Array.isArray(doc?.versions)) {
522
+ refuse('response_mismatch', 'the version list served does not name ' + pkg, pkg)
523
+ }
524
+ const versions: VersionEntry[] = []
525
+ for (const e of doc.versions) {
526
+ if ('string' !== typeof e?.version || !VERSION_RE.test(e.version) ||
527
+ 'string' !== typeof e?.seen) {
528
+ refuse('response_mismatch', 'the version list for ' + pkg + ' is malformed', pkg)
529
+ }
530
+ versions.push({ version: e.version, seen: e.seen })
531
+ }
532
+ versions.sort((a, b) => versionCompare(a.version, b.version))
533
+ // Every version the list offers is a version this client has seen:
534
+ // its absence later is a rollback whichever version was taken.
535
+ if ('' !== ctx.cache) {
536
+ const subject = trustEntryFor(ctx.config, pkg).signer
537
+ for (const e of versions) {
538
+ recordSeen(ctx, pkg, e.version, subject)
539
+ }
540
+ }
541
+ return versions
542
+ }
543
+
544
+
545
+ async function fetchAdvisory(ctx: AcquireCtx, bases: string[], pkg: string):
546
+ Promise<Record<string, string>> {
547
+ const r = await getObject(ctx, bases, objectPath('advisory', pkg))
548
+ const out: Record<string, string> = {}
549
+ if (200 !== r.status) {
550
+ return out
551
+ }
552
+ const doc = parseDoc(r.body)
553
+ for (const e of Array.isArray(doc?.retracted) ? doc.retracted : []) {
554
+ if ('string' === typeof e?.version && 'string' === typeof e?.by) {
555
+ out[e.version] = e.by
556
+ }
557
+ }
558
+ return out
559
+ }
560
+
561
+
562
+ // The versions this client has seen for a package, from its own
563
+ // records: a version absent from the list now is a rollback.
564
+ function seenVersions(ctx: AcquireCtx, pkg: string): string[] {
565
+ const dir = cacheSeenDir(ctx.cache, pkg)
566
+ if (!existsSync(dir)) {
567
+ return []
568
+ }
569
+ return readdirSync(dir).filter((f) => f.endsWith('.aon'))
570
+ .map((f) => f.slice(0, -'.aon'.length)).sort(cmpBytes)
571
+ }
572
+
573
+ function recordSeen(ctx: AcquireCtx, pkg: string, version: string, subject: string): void {
574
+ const dir = cacheSeenDir(ctx.cache, pkg)
575
+ const file = pathJoin(dir, version + '.aon')
576
+ if (existsSync(file)) {
577
+ return
578
+ }
579
+ mkdirSync(dir, { recursive: true })
580
+ writeFileSync(file, canonLine(
581
+ { package: pkg, version, seen: timestamp(ctx.now()), subject }, ctx.options, 'seen.aon'))
582
+ }
583
+
584
+
585
+ // Selection (spec acquire step 6): the newest version outside the
586
+ // cooldown, timed from the repository's first-seen time (ADR-039 part
587
+ // 9), not retracted. A version named explicitly is taken as it is.
588
+ function selectVersion(ctx: AcquireCtx, pkg: string, list: VersionEntry[],
589
+ advisory: Record<string, string>, asked: string | undefined,
590
+ fallback?: string): string {
591
+ if (undefined !== asked) {
592
+ return asked
593
+ }
594
+ const priv = isPrivateName(ctx.config, pkg)
595
+ const now = ctx.now().getTime()
596
+ let held: VersionEntry | undefined
597
+ for (let i = list.length - 1; 0 <= i; i--) {
598
+ const e = list[i]
599
+ if (null != advisory[e.version]) {
600
+ continue
601
+ }
602
+ const seen = Date.parse(e.seen)
603
+ const until = seen + COOLDOWN_HOURS * 3600 * 1000
604
+ if (!priv && (Number.isNaN(seen) || now < until)) {
605
+ held = held ?? e
606
+ continue
607
+ }
608
+ if (null != held) {
609
+ ctx.events.push({
610
+ code: 'cooldown_pending',
611
+ message: pkg + ' ' + held.version + ' is inside the cooldown until ' +
612
+ timestamp(new Date(Date.parse(held.seen) + COOLDOWN_HOURS * 3600 * 1000)) +
613
+ '; ' + e.version + ' was selected',
614
+ })
615
+ }
616
+ return e.version
617
+ }
618
+ const why = null == held ? pkg + ' has no selectable version' :
619
+ pkg + ' ' + held.version + ' is inside the cooldown until ' +
620
+ timestamp(new Date(Date.parse(held.seen) + COOLDOWN_HOURS * 3600 * 1000)) +
621
+ ' and no earlier version is selectable'
622
+ if (undefined !== fallback) {
623
+ ctx.events.push({ code: 'cooldown_pending', message: why })
624
+ return fallback
625
+ }
626
+ refuse(null == held ? 'fetch_failed' : 'cooldown_pending', why, pkg)
627
+ }
628
+
629
+
630
+ async function fetchManifest(ctx: AcquireCtx, bases: string[], pkg: string,
631
+ version: string): Promise<{ bytes: Uint8Array, manifest: any }> {
632
+ const cached = pathJoin(cacheDownloadDir(ctx.cache, pkg), version + '.manifest')
633
+ let bytes: Uint8Array
634
+ if (existsSync(cached)) {
635
+ bytes = new Uint8Array(readFileSync(cached))
636
+ }
637
+ else {
638
+ const r = await getObject(ctx, bases, objectPath('manifest', pkg, version))
639
+ if (200 !== r.status) {
640
+ const t = await getObject(ctx, bases, objectPath('tombstone', pkg, version))
641
+ if (200 === t.status) {
642
+ const doc = parseDoc(t.body)
643
+ refuse('tombstoned', pkg + ' ' + version + ' was withdrawn by the repository' +
644
+ ('string' === typeof doc?.reason ? ' (' + doc.reason + ')' : ''), pkg)
645
+ }
646
+ refuse('fetch_failed', 'no manifest for ' + pkg + ' ' + version, pkg)
647
+ }
648
+ bytes = r.body
649
+ }
650
+ const manifest = parseDoc(bytes)
651
+ const bad = manifestError(manifest)
652
+ if (undefined !== bad) {
653
+ refuse('manifest_invalid', 'the manifest for ' + pkg + ' ' + version + ': ' + bad, pkg)
654
+ }
655
+ if (manifest.package !== pkg || manifest.version !== version) {
656
+ refuse('response_mismatch', 'the manifest served names ' + manifest.package + ' ' +
657
+ manifest.version + ', not ' + pkg + ' ' + version, pkg)
658
+ }
659
+ return { bytes, manifest }
660
+ }
661
+
662
+
663
+ async function fetchProof(ctx: AcquireCtx, bases: string[], pkg: string,
664
+ version: string, entry: TrustEntry, manifestDigest: string): Promise<Uint8Array> {
665
+ if ('forge' === entry.signer) {
666
+ refuse('proof_signer_untrusted', 'the trust entry for ' + pkg +
667
+ ' names the forge signer, and this build verifies key proofs only;' +
668
+ ' name a key under repo.trust', pkg)
669
+ }
670
+ const cached = pathJoin(cacheDownloadDir(ctx.cache, pkg), version + '.sig')
671
+ let bytes: Uint8Array
672
+ if (existsSync(cached)) {
673
+ bytes = new Uint8Array(readFileSync(cached))
674
+ }
675
+ else {
676
+ const r = await getObject(ctx, bases, objectPath('signature', pkg, version))
677
+ if (200 !== r.status) {
678
+ refuse('proof_missing', 'no proof is served for ' + pkg + ' ' + version, pkg)
679
+ }
680
+ bytes = r.body
681
+ }
682
+ const bad = verifyKeyProof(parseDoc(bytes), manifestDigest, entry.signer)
683
+ if (undefined !== bad) {
684
+ refuse(bad.startsWith('signed by') ? 'proof_signer_untrusted' : 'proof_invalid',
685
+ 'the proof for ' + pkg + ' ' + version + ': ' + bad, pkg)
686
+ }
687
+ if ('required' === entry.inclusion) {
688
+ refuse('inclusion_missing', 'the trust entry for ' + pkg +
689
+ ' requires log inclusion, which a key proof does not carry in this build;' +
690
+ ' set inclusion: none for a key signer', pkg)
691
+ }
692
+ return bytes
693
+ }
694
+
695
+
696
+ async function fetchArchive(ctx: AcquireCtx, bases: string[], pkg: string,
697
+ version: string, manifest: any): Promise<Uint8Array> {
698
+ const cached = pathJoin(cacheDownloadDir(ctx.cache, pkg), version + '.zip')
699
+ let bytes: Uint8Array
700
+ if (existsSync(cached)) {
701
+ bytes = new Uint8Array(readFileSync(cached))
702
+ }
703
+ else {
704
+ const r = await getObject(ctx, bases, objectPath('archive', pkg, version))
705
+ if (200 !== r.status) {
706
+ refuse('fetch_failed', 'no archive for ' + pkg + ' ' + version, pkg)
707
+ }
708
+ bytes = r.body
709
+ }
710
+ if (ARCHIVE_LIMITS.bytes < bytes.length) {
711
+ refuse('archive_too_large', 'the archive for ' + pkg + ' ' + version +
712
+ ' is over the compressed cap', pkg)
713
+ }
714
+ const digest = sha256Hex(bytes)
715
+ if (digest !== manifest.archive.digest) {
716
+ refuse('archive_digest_mismatch', 'the archive for ' + pkg + ' ' + version +
717
+ ' is ' + digest + ', not ' + manifest.archive.digest, pkg)
718
+ }
719
+ return bytes
720
+ }
721
+
722
+
723
+ // Unpack (spec acquire step 11): the entry rules the write path applies,
724
+ // applied again here, because only this protects against a hostile
725
+ // mirror; then every file against the manifest.
726
+ function unpack(pkg: string, version: string, zip: Uint8Array, manifest: any):
727
+ { path: string, data: Uint8Array }[] {
728
+ let entries: { path: string, data: Uint8Array }[]
729
+ try {
730
+ entries = unzipCanonical(zip)
731
+ }
732
+ catch (e: any) {
733
+ refuse('archive_not_canonical', 'the archive for ' + pkg + ' ' + version + ': ' +
734
+ e.message, pkg)
735
+ }
736
+ if (ARCHIVE_LIMITS.files < entries.length) {
737
+ refuse('archive_too_many_files', 'the archive for ' + pkg + ' ' + version +
738
+ ' is over the file-count cap', pkg)
739
+ }
740
+ let total = 0
741
+ const listed = new Map<string, ArchiveFile>(
742
+ manifest.archive.files.map((f: ArchiveFile) => [f.path, f]))
743
+ for (const e of entries) {
744
+ const bad = relPathError(e.path)
745
+ if (undefined !== bad) {
746
+ refuse('archive_path_invalid', 'the archive for ' + pkg + ' ' + version + ': ' +
747
+ bad + ' (' + e.path + ')', pkg)
748
+ }
749
+ if (!archiveAdmits(e.path)) {
750
+ refuse('archive_entry_forbidden', 'the archive for ' + pkg + ' ' + version +
751
+ ' carries ' + e.path + ', which the allowlist does not admit', pkg)
752
+ }
753
+ total += e.data.length
754
+ if (ARCHIVE_LIMITS.fileBytes < e.data.length || ARCHIVE_LIMITS.unpacked < total) {
755
+ refuse('archive_bomb', 'the archive for ' + pkg + ' ' + version +
756
+ ' unpacks past the size cap', pkg)
757
+ }
758
+ const f = listed.get(e.path)
759
+ if (undefined === f || f.digest !== sha256Hex(e.data) || f.size !== e.data.length) {
760
+ refuse('file_manifest_mismatch', 'the archive for ' + pkg + ' ' + version +
761
+ ' holds ' + e.path + ', which the manifest does not list as served', pkg)
762
+ }
763
+ listed.delete(e.path)
764
+ }
765
+ if (0 < listed.size) {
766
+ refuse('file_manifest_mismatch', 'the archive for ' + pkg + ' ' + version +
767
+ ' lacks ' + [...listed.keys()].sort(cmpBytes)[0] + ', which the manifest lists', pkg)
768
+ }
769
+ return entries
770
+ }
771
+
772
+
773
+ function writeTree(dir: string, entries: { path: string, data: Uint8Array }[]): void {
774
+ for (const e of entries) {
775
+ const full = pathJoin(dir, ...e.path.split('/'))
776
+ mkdirSync(pathDirname(full), { recursive: true })
777
+ writeFileSync(full, e.data)
778
+ }
779
+ }
780
+
781
+
782
+ // ACQUIRE (spec ops.acquire): one package, its deps first, the pins
783
+ // checked in order -- proof, bytes, meaning -- then recorded, cached
784
+ // and returned for the lock.
785
+ export async function acquire(ctx: AcquireCtx, pkg: string, asked: string | undefined,
786
+ depth: number): Promise<Acquired> {
787
+ if (LIMITS.depth <= depth) {
788
+ refuse('module_depth', 'the closure under ' + pkg + ' nests past ' + LIMITS.depth, pkg)
789
+ }
790
+ if (LIMITS.closure <= ctx.count) {
791
+ refuse('closure_too_large', 'the closure exceeds ' + LIMITS.closure + ' packages', pkg)
792
+ }
793
+ const bases = basesFor(ctx, pkg)
794
+ const entry = trustEntryFor(ctx.config, pkg)
795
+
796
+ const list = await fetchList(ctx, bases, pkg)
797
+ // A version seen before and gone from the list is a rollback, unless
798
+ // the repository says why: a tombstone stands where it was.
799
+ for (const v of seenVersions(ctx, pkg)) {
800
+ if (!list.some((e) => e.version === v) &&
801
+ 200 !== (await getObject(ctx, bases, objectPath('tombstone', pkg, v))).status) {
802
+ refuse('list_rollback', pkg + ' ' + v + ' was seen before and is absent from the list', pkg)
803
+ }
804
+ }
805
+ const advisory = await fetchAdvisory(ctx, bases, pkg)
806
+ const version = selectVersion(ctx, pkg, list, advisory, asked)
807
+ const newest = list[list.length - 1]?.version
808
+ if (undefined !== asked && !list.some((e) => e.version === asked)) {
809
+ const t = await getObject(ctx, bases, objectPath('tombstone', pkg, asked))
810
+ if (200 === t.status) {
811
+ const doc = parseDoc(t.body)
812
+ refuse('tombstoned', pkg + ' ' + asked + ' was withdrawn by the repository' +
813
+ ('string' === typeof doc?.reason ? ' (' + doc.reason + ')' : ''), pkg)
814
+ }
815
+ refuse('fetch_failed', pkg + ' ' + asked + ' is not in the version list', pkg)
816
+ }
817
+
818
+ const id = pkg + '@' + version
819
+ const had = ctx.acquired[id]
820
+ if (undefined !== had) {
821
+ return had
822
+ }
823
+ ctx.count++
824
+
825
+ // The newest version speaks for the name: a move declared there
826
+ // refuses every version, and nothing follows it.
827
+ if (undefined !== newest && newest !== version) {
828
+ const top = await fetchManifest(ctx, bases, pkg, newest)
829
+ if (null != top.manifest.moved) {
830
+ refuse('module_moved', pkg + ' moved to ' + top.manifest.moved +
831
+ '; import that instead, nothing follows a move', pkg)
832
+ }
833
+ }
834
+ const { bytes: manifestBytes, manifest } = await fetchManifest(ctx, bases, pkg, version)
835
+ if (null != manifest.moved) {
836
+ refuse('module_moved', pkg + ' moved to ' + manifest.moved +
837
+ '; import that instead, nothing follows a move', pkg)
838
+ }
839
+ const manifestDigest = sha256Hex(manifestBytes)
840
+ const proofBytes = await fetchProof(ctx, bases, pkg, version, entry, manifestDigest)
841
+ const zip = await fetchArchive(ctx, bases, pkg, version, manifest)
842
+ const entries = unpack(pkg, version, zip, manifest)
843
+
844
+ // Deps first, at the minima the manifest declares: the module is
845
+ // evaluated in its publisher's context, which is what its canon pins.
846
+ const deps: Record<string, Dependency> = manifest.deps
847
+ const pins: LockEntry[] = []
848
+ for (const key of Object.keys(deps).sort(cmpBytes)) {
849
+ const target = deps[key].pkg ?? key
850
+ if (isAlias(key) && null == deps[key].pkg) {
851
+ refuse('manifest_invalid', 'the manifest for ' + pkg + ' ' + version +
852
+ ' declares ' + key + ' without the package it names', pkg)
853
+ }
854
+ const dep = await acquire(ctx, target, deps[key].v, depth + 1)
855
+ pins.push({
856
+ key, v: dep.version, canon: dep.canon, archive: dep.archive,
857
+ manifest: dep.manifestDigest, ...(isAlias(key) ? { pkg: target } : {}),
858
+ })
859
+ for (const e of dep.closure) {
860
+ if (!pins.some((p) => p.key === e.key)) {
861
+ pins.push(e)
862
+ }
863
+ }
864
+ }
865
+ pins.sort((a, b) => cmpBytes(a.key, b.key))
866
+
867
+ const tmp = pathJoin(ctx.cache, 'tmp', randomBytes(8).toString('hex'))
868
+ mkdirSync(pathJoin(tmp, META_DIR), { recursive: true })
869
+ writeTree(tmp, entries)
870
+ writeFileSync(pathJoin(tmp, META_DIR, 'manifest.aon'), manifestBytes)
871
+ writeFileSync(pathJoin(tmp, META_DIR, 'proof.aon'), proofBytes)
872
+ const self = packageSelf(tmp, ctx.options)
873
+ const mod = manifest.modules[0]
874
+ if (self.path !== pkg || self.version !== version || self.main !== mod.main) {
875
+ rmSync(tmp, { recursive: true, force: true })
876
+ refuse('manifest_invalid', 'the package file inside ' + pkg + ' ' + version +
877
+ ' disagrees with the manifest', pkg)
878
+ }
879
+ // The store tree keeps its own lock: the closure it was verified
880
+ // against, so it verifies again from the cache alone. The vendored
881
+ // copy loses it, and resolves against the consumer's lock instead.
882
+ if (0 < pins.length) {
883
+ writeLock(tmp, pins, ctx.options)
884
+ }
885
+ // The entry is in the archive: the manifest named it among the files
886
+ // and every listed file was unpacked.
887
+ const main = pathJoin(tmp, mod.main)
888
+ const got = ctx.options.eval(readFileSync(main, 'utf8'), main)
889
+ if (!got.ok || got.hash !== mod.canon) {
890
+ rmSync(tmp, { recursive: true, force: true })
891
+ refuse('module_integrity', pkg + ' ' + version + ' means ' +
892
+ (!got.ok ? 'nothing (it does not evaluate)' : got.hash) +
893
+ ', and the manifest pins ' + mod.canon, pkg)
894
+ }
895
+
896
+ const dir = cacheStoreDir(ctx.cache, got.hash, pkg)
897
+ rmSync(dir, { recursive: true, force: true })
898
+ mkdirSync(pathDirname(dir), { recursive: true })
899
+ renameSync(tmp, dir)
900
+
901
+ const down = cacheDownloadDir(ctx.cache, pkg)
902
+ mkdirSync(down, { recursive: true })
903
+ for (const [name, data] of [
904
+ [version + '.zip', zip], [version + '.manifest', manifestBytes],
905
+ [version + '.sig', proofBytes]] as [string, Uint8Array][]) {
906
+ if (!existsSync(pathJoin(down, name))) {
907
+ writeFileSync(pathJoin(down, name), data)
908
+ }
909
+ }
910
+ recordSeen(ctx, pkg, version, entry.signer)
911
+
912
+ const out: Acquired = {
913
+ pkg, version, canon: got.hash, archive: sha256Hex(zip), manifestDigest,
914
+ deps, dir, closure: pins,
915
+ }
916
+ ctx.acquired[id] = out
917
+ ctx.fetched.push(pkg + ' ' + version)
918
+ return out
919
+ }
920
+
921
+
922
+ export type PkgSyncReport = {
923
+ verdict: 'ok' | 'frozen' | 'refused' | 'missing' | 'error' | 'mismatch' | 'unlocked'
924
+ fetched: string[]
925
+ lock: LockEntry[]
926
+ vendored: string[]
927
+ missing: string[]
928
+ unevaluable: string[]
929
+ forbidden: string[]
930
+ mismatched: PkgMismatch[]
931
+ unlocked: string[]
932
+ changes: string[]
933
+ events: PkgEvent[]
934
+ refusal?: PkgRefusalReport
935
+ }
936
+
937
+ export type SyncArgs = RepoOverrides & {
938
+ frozen?: boolean
939
+ now?: () => Date
940
+ }
941
+
942
+
943
+ function emptySync(): PkgSyncReport {
944
+ return {
945
+ verdict: 'ok', fetched: [], lock: [], vendored: [], missing: [], unevaluable: [],
946
+ forbidden: [], mismatched: [], unlocked: [], changes: [], events: [],
947
+ }
948
+ }
949
+
950
+ function refused(report: PkgSyncReport, e: unknown): PkgSyncReport {
951
+ if (!(e instanceof PkgRefusal)) {
952
+ throw e
953
+ }
954
+ report.verdict = 'refused'
955
+ report.refusal = { code: e.code, message: e.message, ...(null == e.pkg ? {} : { pkg: e.pkg }) }
956
+ return report
957
+ }
958
+
959
+
960
+ function makeCtx(root: string, options: PkgToolOptions, http: PkgHttp,
961
+ args: SyncArgs): AcquireCtx {
962
+ return {
963
+ options, http, config: repoConfig(root, options, args),
964
+ cache: options.cache as string,
965
+ now: args.now ?? (() => new Date()),
966
+ events: [], fetched: [], acquired: {}, count: 0,
967
+ }
968
+ }
969
+
970
+
971
+ // Where the project already holds a package at a version: its vendor
972
+ // tree, when the tree's own file agrees, else the cache, when the
973
+ // download tree has that version's manifest.
974
+ function heldAt(root: string, key: string, pkg: string, version: string,
975
+ ctx: AcquireCtx): string | undefined {
976
+ const vendored = moduleDir(pathJoin(root, META_DIR, VENDOR_DIR), key)
977
+ if (existsSync(pathJoin(vendored, PKG_FILE))) {
978
+ const self = packageSelf(vendored, ctx.options)
979
+ if (('' === self.version || self.version === version) &&
980
+ ('' === self.path || self.path === pkg)) {
981
+ return vendored
982
+ }
983
+ }
984
+ const manifestFile = pathJoin(cacheDownloadDir(ctx.cache, pkg), version + '.manifest')
985
+ if (existsSync(manifestFile)) {
986
+ const m = parseDoc(new Uint8Array(readFileSync(manifestFile)))
987
+ const canon = m?.modules?.[0]?.canon
988
+ if ('string' === typeof canon) {
989
+ const dir = cacheStoreDir(ctx.cache, canon, pkg)
990
+ if (existsSync(pathJoin(dir, PKG_FILE))) {
991
+ return dir
992
+ }
993
+ }
994
+ }
995
+ return undefined
996
+ }
997
+
998
+
999
+ // SYNC (spec ops.sync): resolve by MVS, fetch what is missing, vendor,
1000
+ // lock, verify. Idempotent, and `--frozen` refuses to change the lock.
1001
+ export async function pkgSync(root: string, options: PkgToolOptions, http: PkgHttp,
1002
+ args: SyncArgs = {}): Promise<PkgSyncReport> {
1003
+ const report = emptySync()
1004
+ let ctx: AcquireCtx
1005
+ try {
1006
+ ctx = makeCtx(root, options, http, args)
1007
+ }
1008
+ catch (e) {
1009
+ return refused(report, e)
1010
+ }
1011
+
1012
+ const previous = readLock(root)
1013
+ const selected: Record<string, Dependency> = {}
1014
+ const dirs: Record<string, string> = {}
1015
+ const missing: string[] = []
1016
+ const pending = new Set<string>()
1017
+ const bid = (deps: Record<string, Dependency>) => {
1018
+ for (const key of Object.keys(deps)) {
1019
+ const have = selected[key]
1020
+ if (null == have || 0 > versionCompare(have.v, deps[key].v)) {
1021
+ selected[key] = { ...have, ...deps[key] }
1022
+ pending.add(key)
1023
+ }
1024
+ }
1025
+ }
1026
+ const target = (key: string) => targetOf(key, selected[key], previous[key])
1027
+
1028
+ // Held packages are read before anything is fetched, so a held
1029
+ // dependant's bid raises a version before that version is requested.
1030
+ try {
1031
+ bid(declaredDeps(pathJoin(root, PKG_FILE), options))
1032
+ for (; 0 < pending.size;) {
1033
+ const keys = [...pending].sort(cmpBytes)
1034
+ let key = keys.find((k) => !usableKey(k) || '' === target(k))
1035
+ if (undefined !== key) {
1036
+ missing.push(key)
1037
+ pending.delete(key)
1038
+ continue
1039
+ }
1040
+ let dir: string | undefined
1041
+ for (const k of keys) {
1042
+ dir = heldAt(root, k, target(k), selected[k].v, ctx)
1043
+ if (undefined !== dir) {
1044
+ key = k
1045
+ break
1046
+ }
1047
+ }
1048
+ if (undefined === key) {
1049
+ key = keys.find((k) => true !== args.frozen || previous[k]?.v === selected[k].v)
1050
+ if (undefined === key) {
1051
+ for (const k of keys) {
1052
+ report.changes.push(k + ': ' + (previous[k]?.v ?? 'unlocked') + ' -> ' + selected[k].v)
1053
+ }
1054
+ report.verdict = 'frozen'
1055
+ return report
1056
+ }
1057
+ dir = (await acquire(ctx, target(key), selected[key].v, 0)).dir
1058
+ }
1059
+ pending.delete(key)
1060
+ dirs[key] = dir as string
1061
+ bid(declaredDeps(pathJoin(dir as string, PKG_FILE), options))
1062
+ }
1063
+ }
1064
+ catch (e) {
1065
+ report.events = ctx.events
1066
+ report.fetched = ctx.fetched.sort(cmpBytes)
1067
+ return refused(report, e)
1068
+ }
1069
+
1070
+ report.events = ctx.events
1071
+ report.fetched = ctx.fetched.sort(cmpBytes)
1072
+
1073
+ // Materialise: the closure into the vendor tree, and nothing else
1074
+ // left there.
1075
+ const vendorRoot = pathJoin(root, META_DIR, VENDOR_DIR)
1076
+ for (const key of Object.keys(dirs).sort(cmpBytes)) {
1077
+ const to = moduleDir(vendorRoot, key)
1078
+ if (dirs[key] !== to) {
1079
+ rmSync(to, { recursive: true, force: true })
1080
+ vendorCopy(dirs[key], to)
1081
+ }
1082
+ report.vendored.push(key)
1083
+ }
1084
+ // Pruning waits for the lock to be writable: a frozen sync that
1085
+ // refuses leaves the locked build whole.
1086
+ const prune = (): void => {
1087
+ for (const key of Object.keys(previous)) {
1088
+ if (null == selected[key] && usableKey(key)) {
1089
+ rmSync(moduleDir(vendorRoot, key), { recursive: true, force: true })
1090
+ pruneEmpty(pathDirname(moduleDir(vendorRoot, key)), vendorRoot)
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ const resolved = pkgResolve(root, options)
1096
+ report.lock = resolved.lock
1097
+ report.missing = [...new Set([...missing, ...resolved.missing])].sort(cmpBytes)
1098
+ report.unevaluable = resolved.unevaluable
1099
+ report.forbidden = resolved.forbidden
1100
+ if ('ok' !== resolved.verdict || 0 < report.missing.length) {
1101
+ report.verdict = 0 < report.unevaluable.length || 0 < report.forbidden.length ?
1102
+ 'error' : 'missing'
1103
+ return report
1104
+ }
1105
+
1106
+ const lockFile = pathJoin(root, META_DIR, LOCK_FILE)
1107
+ const before = existsSync(lockFile) ? readFileSync(lockFile, 'utf8') : ''
1108
+ const after = lockText(resolved.lock, options)
1109
+ if (true === args.frozen && before.split('\n')[before.startsWith('#') ? 1 : 0] !== after) {
1110
+ for (const e of resolved.lock) {
1111
+ const p = previous[e.key]
1112
+ if (null == p || p.canon !== e.canon || p.archive !== e.archive ||
1113
+ p.v !== e.v || p.manifest !== e.manifest) {
1114
+ report.changes.push(e.key + ': ' + (null == p ? 'unlocked' : 'repinned'))
1115
+ }
1116
+ }
1117
+ for (const key of Object.keys(previous)) {
1118
+ if (!resolved.lock.some((e) => e.key === key)) {
1119
+ report.changes.push(key + ': dropped')
1120
+ }
1121
+ }
1122
+ report.verdict = 'frozen'
1123
+ return report
1124
+ }
1125
+ prune()
1126
+ writeLock(root, resolved.lock, options)
1127
+
1128
+ const verify = pkgVerify(root, options)
1129
+ report.mismatched = verify.mismatched
1130
+ report.unlocked = verify.unlocked
1131
+ report.verdict = verify.verdict
1132
+ return report
1133
+ }
1134
+
1135
+
1136
+ function pruneEmpty(dir: string, stop: string): void {
1137
+ for (; dir.startsWith(stop) && existsSync(dir) && 0 === readdirSync(dir).length;
1138
+ dir = pathDirname(dir)) {
1139
+ rmSync(dir, { recursive: true })
1140
+ }
1141
+ }
1142
+
1143
+
1144
+ // EDITING THE PACKAGE FILE. It is authored, so the edits are the
1145
+ // smallest text changes that keep it the author's: a line appended, a
1146
+ // version literal rewritten where it stands, a one-line entry removed.
1147
+ export type DepEdit =
1148
+ | { op: 'add', key: string, v: string }
1149
+ | { op: 'raise', key: string, v: string }
1150
+ | { op: 'remove', key: string }
1151
+
1152
+ export function editDeps(root: string, edit: DepEdit, options: PkgToolOptions):
1153
+ string | undefined {
1154
+ const file = pathJoin(root, PKG_FILE)
1155
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : ''
1156
+ let after: string
1157
+ if ('add' === edit.op) {
1158
+ after = before + ('' === before || before.endsWith('\n') ? '' : '\n') +
1159
+ 'dep: ' + JSON.stringify(edit.key) + ': { v: ' + JSON.stringify(edit.v) + ' }\n'
1160
+ }
1161
+ else {
1162
+ const lines = before.split('\n')
1163
+ const at = lines.findIndex((l) => l.includes(JSON.stringify(edit.key)))
1164
+ if (0 > at) {
1165
+ return edit.key + ' is not on one line of ' + PKG_FILE + '; edit it by hand'
1166
+ }
1167
+ const line = lines[at]
1168
+ if ((line.match(/\{/g) ?? []).length !== (line.match(/\}/g) ?? []).length) {
1169
+ return edit.key + ' spans several lines of ' + PKG_FILE + '; edit it by hand'
1170
+ }
1171
+ if ('raise' === edit.op) {
1172
+ const re = /\bv\s*:\s*"[^"]*"/
1173
+ if (!re.test(line)) {
1174
+ return edit.key + ' declares its version on another line of ' + PKG_FILE +
1175
+ '; edit it by hand'
1176
+ }
1177
+ lines[at] = line.replace(re, 'v: ' + JSON.stringify(edit.v))
1178
+ }
1179
+ else {
1180
+ lines.splice(at, 1)
1181
+ }
1182
+ after = lines.join('\n')
1183
+ }
1184
+ writeFileSync(file, after)
1185
+ const deps = declaredDeps(file, options)
1186
+ const held = options.eval(after, file).ok &&
1187
+ ('remove' === edit.op ? null == deps[edit.key] : deps[edit.key]?.v === edit.v)
1188
+ if (!held) {
1189
+ writeFileSync(file, before)
1190
+ return 'the edit to ' + PKG_FILE + ' did not take; edit it by hand'
1191
+ }
1192
+ return undefined
1193
+ }
1194
+
1195
+
1196
+ export type PkgChangeReport = PkgSyncReport & { change: string }
1197
+
1198
+ export type ChangeArgs = SyncArgs & { mode: 'add' | 'get' }
1199
+
1200
+ // A `<pkg>[@<version>]` argument.
1201
+ export function parsePkgSpec(spec: string): { pkg: string, version?: string } | string {
1202
+ const at = spec.indexOf('@')
1203
+ const pkg = 0 > at ? spec : spec.slice(0, at)
1204
+ const version = 0 > at ? undefined : spec.slice(at + 1)
1205
+ if (!packagePath(pkg)) {
1206
+ return 'not a package path: ' + spec
1207
+ }
1208
+ if (undefined !== version && !VERSION_RE.test(version)) {
1209
+ return 'not a version: ' + version + ' (MAJOR.MINOR.PATCH)'
1210
+ }
1211
+ return { pkg, ...(undefined === version ? {} : { version }) }
1212
+ }
1213
+
1214
+
1215
+ // `aontu add` and `aontu get`: a dependency declared or raised, then a
1216
+ // sync. `add` refuses what is already declared and names `get`.
1217
+ export async function pkgGet(root: string, options: PkgToolOptions, http: PkgHttp,
1218
+ spec: string, args: ChangeArgs): Promise<PkgChangeReport | string> {
1219
+ const parsed = parsePkgSpec(spec)
1220
+ if ('string' === typeof parsed) {
1221
+ return parsed
1222
+ }
1223
+ const { pkg } = parsed
1224
+ const declared = declaredDeps(pathJoin(root, PKG_FILE), options)
1225
+ const have = declared[pkg]
1226
+ if ('add' === args.mode && null != have) {
1227
+ return pkg + ' is already a dependency at ' + have.v + ' (aontu get raises it)'
1228
+ }
1229
+
1230
+ let version = parsed.version
1231
+ let ctx: AcquireCtx | undefined
1232
+ try {
1233
+ ctx = makeCtx(root, options, http, args)
1234
+ if (undefined === version) {
1235
+ const bases = basesFor(ctx, pkg)
1236
+ const list = await fetchList(ctx, bases, pkg)
1237
+ version = selectVersion(ctx, pkg, list, await fetchAdvisory(ctx, bases, pkg), undefined)
1238
+ }
1239
+ }
1240
+ catch (e) {
1241
+ const report: PkgChangeReport = { ...refused(emptySync(), e), change: 'none' }
1242
+ report.events = ctx?.events ?? []
1243
+ return report
1244
+ }
1245
+
1246
+ let change: string
1247
+ const before = snapshot(root)
1248
+ if (null == have) {
1249
+ const bad = editDeps(root, { op: 'add', key: pkg, v: version }, options)
1250
+ if (undefined !== bad) {
1251
+ return bad
1252
+ }
1253
+ change = 'added ' + pkg + ' ' + version
1254
+ }
1255
+ else if (0 <= versionCompare(have.v, version)) {
1256
+ change = pkg + ' is at ' + have.v + ' already'
1257
+ }
1258
+ else {
1259
+ const bad = editDeps(root, { op: 'raise', key: pkg, v: version }, options)
1260
+ if (undefined !== bad) {
1261
+ return bad
1262
+ }
1263
+ change = 'raised ' + pkg + ' ' + have.v + ' -> ' + version
1264
+ }
1265
+
1266
+ const sync = await pkgSync(root, options, http, { ...args, frozen: false })
1267
+ sync.events = [...ctx.events, ...sync.events]
1268
+ return { ...sync, change: settled(root, before, sync, change) }
1269
+ }
1270
+
1271
+
1272
+ // Everything a sync may change, kept aside: the package file, the
1273
+ // lock, and the vendor tree, copied under the project's own tmp.
1274
+ type Snapshot = { pkgFile: string, lock?: string, vendor?: string }
1275
+
1276
+ function snapshot(root: string): Snapshot {
1277
+ const snap: Snapshot = { pkgFile: readFileSync(pathJoin(root, PKG_FILE), 'utf8') }
1278
+ const lockFile = pathJoin(root, META_DIR, LOCK_FILE)
1279
+ if (existsSync(lockFile)) {
1280
+ snap.lock = readFileSync(lockFile, 'utf8')
1281
+ }
1282
+ const vendorRoot = pathJoin(root, META_DIR, VENDOR_DIR)
1283
+ if (existsSync(vendorRoot)) {
1284
+ snap.vendor = pathJoin(root, META_DIR, 'tmp', randomBytes(8).toString('hex'))
1285
+ copyTree(vendorRoot, snap.vendor)
1286
+ }
1287
+ return snap
1288
+ }
1289
+
1290
+ // A change the sync could not carry is taken back, lock and vendor
1291
+ // tree included: neither verb leaves the project half-changed.
1292
+ function settled(root: string, before: Snapshot, sync: PkgSyncReport, change: string): string {
1293
+ const tmp = pathJoin(root, META_DIR, 'tmp')
1294
+ if ('ok' === sync.verdict) {
1295
+ rmSync(tmp, { recursive: true, force: true })
1296
+ return change
1297
+ }
1298
+ writeFileSync(pathJoin(root, PKG_FILE), before.pkgFile)
1299
+ const lockFile = pathJoin(root, META_DIR, LOCK_FILE)
1300
+ if (undefined === before.lock) {
1301
+ rmSync(lockFile, { force: true })
1302
+ }
1303
+ else {
1304
+ writeFileSync(lockFile, before.lock)
1305
+ }
1306
+ const vendorRoot = pathJoin(root, META_DIR, VENDOR_DIR)
1307
+ rmSync(vendorRoot, { recursive: true, force: true })
1308
+ if (undefined !== before.vendor) {
1309
+ renameSync(before.vendor, vendorRoot)
1310
+ }
1311
+ rmSync(tmp, { recursive: true, force: true })
1312
+ return 'none (' + change + ' was taken back)'
1313
+ }
1314
+
1315
+
1316
+ // `aontu remove`: the pair of `add`.
1317
+ export async function pkgRemove(root: string, options: PkgToolOptions, http: PkgHttp,
1318
+ pkg: string, args: SyncArgs): Promise<PkgChangeReport | string> {
1319
+ const declared = declaredDeps(pathJoin(root, PKG_FILE), options)
1320
+ if (null == declared[pkg]) {
1321
+ return pkg + ' is not a dependency of this project'
1322
+ }
1323
+ const before = snapshot(root)
1324
+ const bad = editDeps(root, { op: 'remove', key: pkg }, options)
1325
+ if (undefined !== bad) {
1326
+ return bad
1327
+ }
1328
+ const sync = await pkgSync(root, options, http, { ...args, frozen: false })
1329
+ return { ...sync, change: settled(root, before, sync, 'removed ' + pkg) }
1330
+ }
1331
+
1332
+
1333
+ export type PkgWhyReport = {
1334
+ verdict: 'ok' | 'missing'
1335
+ pkg: string
1336
+ paths: string[][]
1337
+ }
1338
+
1339
+ // `aontu why`: every chain of dependencies from the project to a
1340
+ // package, read from the lock and the package files in the store.
1341
+ export function pkgWhy(root: string, options: PkgToolOptions, pkg: string): PkgWhyReport {
1342
+ const locked = readLock(root)
1343
+ const self = packageSelf(root, options)
1344
+ const rootKey = '' === self.path ? '.' : self.path
1345
+ const edges: Record<string, string[]> = {
1346
+ [rootKey]: Object.keys(declaredDeps(pathJoin(root, PKG_FILE), options)).sort(cmpBytes),
1347
+ }
1348
+ for (const key of Object.keys(locked)) {
1349
+ const entry = locked[key]
1350
+ const dir = usableKey(key) ?
1351
+ storeDirOf(root, key, entry, options) : undefined
1352
+ edges[key] = undefined === dir ? [] :
1353
+ Object.keys(declaredDeps(pathJoin(dir, PKG_FILE), options)).sort(cmpBytes)
1354
+ }
1355
+
1356
+ const paths: string[][] = []
1357
+ const walk = (key: string, trail: string[]): void => {
1358
+ if (key === pkg || (isAlias(key) && locked[key]?.pkg === pkg)) {
1359
+ paths.push([...trail, key])
1360
+ return
1361
+ }
1362
+ if (trail.includes(key)) {
1363
+ return
1364
+ }
1365
+ for (const dep of edges[key] ?? []) {
1366
+ walk(dep, [...trail, key])
1367
+ }
1368
+ }
1369
+ walk(rootKey, [])
1370
+ return { verdict: 0 < paths.length ? 'ok' : 'missing', pkg, paths }
1371
+ }
1372
+
1373
+ function storeDirOf(root: string, key: string, entry: LockEntry,
1374
+ options: PkgToolOptions): string | undefined {
1375
+ const stores = [moduleDir(pathJoin(root, META_DIR, VENDOR_DIR), key)]
1376
+ if (null != options.cache && '' !== entry.canon) {
1377
+ stores.push(cacheStoreDir(options.cache, entry.canon, entry.pkg ?? key))
1378
+ }
1379
+ return stores.find((d) => existsSync(pathJoin(d, PKG_FILE)))
1380
+ }
1381
+
1382
+
1383
+ // THE READ-PATH LAYOUT, written into a directory: what `publish --to`
1384
+ // leaves behind and `pkg serve` serves.
1385
+ export type LayoutWrite = {
1386
+ manifest: any
1387
+ manifestBytes: Uint8Array
1388
+ proofBytes: Uint8Array
1389
+ archive: Uint8Array
1390
+ }
1391
+
1392
+ export function writeLayout(dir: string, w: LayoutWrite, options: PkgToolOptions,
1393
+ now: Date): void {
1394
+ const pkg = w.manifest.package as string
1395
+ const version = w.manifest.version as string
1396
+ if (!packagePath(pkg) || !VERSION_RE.test(version)) {
1397
+ refuse('manifest_invalid', 'the manifest names ' + pkg + ' ' + version +
1398
+ ', not a package path at a version', pkg)
1399
+ }
1400
+ const at = pathJoin(dir, 'pkg', ...pkgUrlPath(pkg).split('/'), '@v')
1401
+ mkdirSync(at, { recursive: true })
1402
+
1403
+ const existing: string[] = readdirSync(at)
1404
+ .filter((f) => f.endsWith('.manifest')).map((f) => f.slice(0, -'.manifest'.length))
1405
+ if (existing.includes(version)) {
1406
+ refuse('version_exists', pkg + ' ' + version + ' was published before and is never reusable', pkg)
1407
+ }
1408
+ const manifests: any[] = existing.map((v) =>
1409
+ parseDoc(new Uint8Array(readFileSync(pathJoin(at, v + '.manifest')))))
1410
+ for (const m of manifests) {
1411
+ if (null != m?.moved) {
1412
+ refuse('path_moved', pkg + ' is frozen by a moved declaration (now ' + m.moved + ')', pkg)
1413
+ }
1414
+ }
1415
+
1416
+ writeFileSync(pathJoin(at, version + '.zip'), w.archive)
1417
+ writeFileSync(pathJoin(at, version + '.manifest'), w.manifestBytes)
1418
+ writeFileSync(pathJoin(at, version + '.sig'), w.proofBytes)
1419
+
1420
+ const listFile = pathJoin(at, 'list')
1421
+ const old = existsSync(listFile) ? parseDoc(new Uint8Array(readFileSync(listFile))) : undefined
1422
+ const versions: VersionEntry[] = Array.isArray(old?.versions) ? old.versions : []
1423
+ versions.push({ version, seen: timestamp(now) })
1424
+ versions.sort((a, b) => versionCompare(a.version, b.version))
1425
+ writeFileSync(listFile, canonLine({ package: pkg, versions }, options, 'list'))
1426
+ const top = versions[versions.length - 1]
1427
+ writeFileSync(pathJoin(pathDirname(at), '@latest'),
1428
+ canonLine({ package: pkg, version: top.version, seen: top.seen }, options, 'latest'))
1429
+
1430
+ const retracted: { version: string, by: string }[] = []
1431
+ for (const m of [...manifests, w.manifest]) {
1432
+ for (const v of m?.retract ?? []) {
1433
+ retracted.push({ version: v, by: m.version })
1434
+ }
1435
+ }
1436
+ retracted.sort((a, b) => versionCompare(a.version, b.version))
1437
+ const advisory = pathJoin(dir, 'advisory', ...pkgUrlPath(pkg).split('/'))
1438
+ mkdirSync(pathDirname(advisory + '.aon'), { recursive: true })
1439
+ writeFileSync(advisory + '.aon',
1440
+ canonLine({ package: pkg, retracted }, options, 'advisory'))
1441
+ }
1442
+
1443
+
1444
+ // A directory as a repository: the layout above, read by path.
1445
+ export function dirHttp(dir: string): PkgHttp {
1446
+ return {
1447
+ get: async (url: string) => {
1448
+ const p = new URL(url).pathname
1449
+ const file = pathJoin(dir, ...p.split('/').filter((e) => '' !== e))
1450
+ if (p.split('/').includes('..') || !existsSync(file) || !statSync(file).isFile()) {
1451
+ return { status: 404, body: new Uint8Array() }
1452
+ }
1453
+ return { status: 200, body: new Uint8Array(readFileSync(file)) }
1454
+ },
1455
+ post: async () => ({ status: 405, body: utf8('a directory takes no publish') }),
1456
+ }
1457
+ }
1458
+
1459
+
1460
+ export type PkgPublishReport = {
1461
+ verdict: 'dry-run' | 'sent' | 'refused' | 'breaking' | 'undecided' | 'error'
1462
+ manifest?: PkgManifest
1463
+ digest?: string
1464
+ signer?: string
1465
+ against?: string
1466
+ to?: string
1467
+ write?: string
1468
+ missing: string[]
1469
+ forbidden: string[]
1470
+ findings: any[]
1471
+ refusal?: PkgRefusalReport
1472
+ }
1473
+
1474
+ export type PublishArgs = RepoOverrides & {
1475
+ yes?: boolean
1476
+ to?: string
1477
+ key?: string
1478
+ token?: string
1479
+ against?: string
1480
+ now?: () => Date
1481
+ }
1482
+
1483
+
1484
+ // What a forge token says about its bearer, read for the manifest's
1485
+ // publisher block. The write path verifies it; the client copies it.
1486
+ export function publisherFromToken(token: string): any | undefined {
1487
+ const parts = token.trim().split('.')
1488
+ if (3 !== parts.length) {
1489
+ return undefined
1490
+ }
1491
+ let claims: any
1492
+ try {
1493
+ claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
1494
+ }
1495
+ catch {
1496
+ return undefined
1497
+ }
1498
+ const iss: string = claims?.iss ?? ''
1499
+ if ('https://token.actions.githubusercontent.com' === iss) {
1500
+ return {
1501
+ host: 'github.com',
1502
+ namespace: claims.repository,
1503
+ subject: {
1504
+ host: 'github.com', owner_id: String(claims.repository_owner_id),
1505
+ repository_id: String(claims.repository_id),
1506
+ },
1507
+ trigger: 'release' === claims.event_name ? 'release' : 'push',
1508
+ runner: 'self-hosted' === claims.runner_environment ? 'self_hosted' : 'hosted',
1509
+ ...(null == claims.workflow_ref ? {} : { workflow: claims.workflow_ref }),
1510
+ }
1511
+ }
1512
+ if ('https://gitlab.com' === iss) {
1513
+ return {
1514
+ host: 'gitlab.com',
1515
+ namespace: claims.project_path,
1516
+ subject: {
1517
+ host: 'gitlab.com', owner_id: String(claims.namespace_id),
1518
+ repository_id: String(claims.project_id),
1519
+ },
1520
+ trigger: 'push',
1521
+ runner: 'self-hosted' === claims.runner_environment ? 'self_hosted' : 'hosted',
1522
+ ...(null == claims.ci_config_ref_uri ? {} : { workflow: claims.ci_config_ref_uri }),
1523
+ }
1524
+ }
1525
+ return undefined
1526
+ }
1527
+
1528
+
1529
+ // PUBLISH (spec ops.publish, the client's side): the manifest and its
1530
+ // gate, then the signed objects sent, or written into a directory. A
1531
+ // dry run without `--yes`.
1532
+ export async function pkgPublish(root: string, options: PkgToolOptions, http: PkgHttp,
1533
+ args: PublishArgs): Promise<PkgPublishReport> {
1534
+ const now = args.now ?? (() => new Date())
1535
+ const local: PkgManifestReport = pkgManifest(root, options, args.against)
1536
+ const report: PkgPublishReport = {
1537
+ verdict: 'dry-run', missing: local.missing, forbidden: local.forbidden,
1538
+ findings: local.findings,
1539
+ ...(null == args.against ? {} : { against: args.against }),
1540
+ }
1541
+ if ('ok' !== local.verdict) {
1542
+ report.verdict = local.verdict
1543
+ return report
1544
+ }
1545
+ const m = local.manifest as PkgManifest
1546
+ report.manifest = m
1547
+
1548
+ let config: RepoConfig
1549
+ try {
1550
+ config = repoConfig(root, options, args)
1551
+ }
1552
+ catch (e) {
1553
+ return refusedPublish(report, e)
1554
+ }
1555
+ const target = args.to ?? config.write
1556
+ report[null == args.to ? 'write' : 'to'] = target
1557
+
1558
+ // The repository's own predecessor, when one can be read: the highest
1559
+ // existing version is what compatibility is decided against.
1560
+ const reader: PkgHttp = null == args.to ? http : dirHttp(args.to)
1561
+ const ctx: AcquireCtx = {
1562
+ options, http: reader, config: { ...config, private: [] },
1563
+ cache: options.cache ?? '', now, events: [], fetched: [], acquired: {}, count: 0,
1564
+ }
1565
+ const bases = null == args.to ? config.base : ['http://127.0.0.1']
1566
+ try {
1567
+ const listed = await reader.get(bases[0].replace(/\/$/, '') + objectPath('list', m.package))
1568
+ if (200 === listed.status && null == args.against) {
1569
+ const list = await fetchList(ctx, bases, m.package)
1570
+ if (list.some((e) => e.version === m.version)) {
1571
+ refuse('version_exists', m.package + ' ' + m.version +
1572
+ ' was published before and is never reusable', m.package)
1573
+ }
1574
+ const newest = list[list.length - 1]
1575
+ if (undefined !== newest) {
1576
+ const prior = await fetchManifest(ctx, bases, m.package, newest.version)
1577
+ if (null != prior.manifest.moved) {
1578
+ refuse('path_moved', m.package + ' is frozen by a moved declaration (now ' +
1579
+ prior.manifest.moved + ')', m.package)
1580
+ }
1581
+ const zip = await fetchArchive(ctx, bases, m.package, newest.version, prior.manifest)
1582
+ const entries = unpack(m.package, newest.version, zip, prior.manifest)
1583
+ // Under the project's own meta directory, so the predecessor's
1584
+ // imports resolve against the vendor tree the publisher synced.
1585
+ const tmp = pathJoin(root, META_DIR, 'tmp', randomBytes(8).toString('hex'))
1586
+ mkdirSync(tmp, { recursive: true })
1587
+ writeTree(tmp, entries)
1588
+ const gate = pkgManifest(root, options, tmp)
1589
+ rmSync(pathJoin(root, META_DIR, 'tmp'), { recursive: true, force: true })
1590
+ report.against = m.package + ' ' + newest.version
1591
+ report.findings = gate.findings
1592
+ if ('ok' !== gate.verdict) {
1593
+ report.verdict = gate.verdict
1594
+ return report
1595
+ }
1596
+ }
1597
+ }
1598
+ }
1599
+ catch (e) {
1600
+ return refusedPublish(report, e)
1601
+ }
1602
+
1603
+ if (null == args.to && 'public' !== m.publish && !isLoopback(target)) {
1604
+ return refusedPublish(report, new PkgRefusal('not_public',
1605
+ m.package + ' does not declare publish: public; nothing is uploaded', m.package))
1606
+ }
1607
+
1608
+ const full: any = { ...m, published: timestamp(now()) }
1609
+ if (null != args.token) {
1610
+ const publisher = publisherFromToken(readFileSync(args.token, 'utf8'))
1611
+ if (undefined !== publisher) {
1612
+ full.publisher = publisher
1613
+ }
1614
+ }
1615
+ const manifestBytes = utf8(manifestText(full, options) + '\n')
1616
+ report.digest = sha256Hex(manifestBytes)
1617
+ if (null != args.key) {
1618
+ try {
1619
+ report.signer = keyIdFromPem(readFileSync(args.key, 'utf8'))
1620
+ }
1621
+ catch (e) {
1622
+ return refusedPublish(report, e)
1623
+ }
1624
+ }
1625
+ if (true !== args.yes) {
1626
+ return report
1627
+ }
1628
+
1629
+ const proof = signDigest(readFileSync(args.key as string, 'utf8'), report.digest)
1630
+ const proofBytes = canonLine(proof, options, 'proof.aon')
1631
+ const archive = archiveOf(root)
1632
+ try {
1633
+ if (null != args.to) {
1634
+ writeLayout(args.to, { manifest: full, manifestBytes, proofBytes, archive: archive.zip },
1635
+ options, now())
1636
+ }
1637
+ else {
1638
+ const r = await http.post(target.replace(/\/$/, '') + PUBLISH_PATH,
1639
+ { manifest: manifestBytes, proof: proofBytes, archive: archive.zip },
1640
+ null == args.token ? '' : readFileSync(args.token, 'utf8').trim())
1641
+ if (200 !== r.status && 201 !== r.status) {
1642
+ const doc = parseDoc(r.body)
1643
+ refuse('string' === typeof doc?.code ? doc.code : 'fetch_failed',
1644
+ 'string' === typeof doc?.message ? doc.message :
1645
+ 'the write path answered ' + r.status, m.package)
1646
+ }
1647
+ }
1648
+ }
1649
+ catch (e) {
1650
+ return refusedPublish(report, e)
1651
+ }
1652
+ report.verdict = 'sent'
1653
+ return report
1654
+ }
1655
+
1656
+ function refusedPublish(report: PkgPublishReport, e: unknown): PkgPublishReport {
1657
+ if (!(e instanceof PkgRefusal)) {
1658
+ throw e
1659
+ }
1660
+ report.verdict = 'refused'
1661
+ report.refusal = { code: e.code, message: e.message, ...(null == e.pkg ? {} : { pkg: e.pkg }) }
1662
+ return report
1663
+ }
1664
+
1665
+
1666
+ export type PkgOutdatedEntry = {
1667
+ key: string
1668
+ v: string
1669
+ newest: string
1670
+ retracted?: string
1671
+ moves: string[]
1672
+ }
1673
+
1674
+ export type PkgOutdatedReport = {
1675
+ verdict: 'current' | 'outdated' | 'refused'
1676
+ locked: PkgOutdatedEntry[]
1677
+ events: PkgEvent[]
1678
+ refusal?: PkgRefusalReport
1679
+ }
1680
+
1681
+
1682
+ // `aontu pkg outdated`: for every locked package, the newest selectable
1683
+ // version, and what a resolution taking it would move with it.
1684
+ export async function pkgOutdated(root: string, options: PkgToolOptions, http: PkgHttp,
1685
+ args: SyncArgs = {}): Promise<PkgOutdatedReport> {
1686
+ const report: PkgOutdatedReport = { verdict: 'current', locked: [], events: [] }
1687
+ let ctx: AcquireCtx
1688
+ try {
1689
+ ctx = makeCtx(root, options, http, args)
1690
+ }
1691
+ catch (e) {
1692
+ return refusedOutdated(report, e)
1693
+ }
1694
+ const locked = readLock(root)
1695
+ try {
1696
+ for (const key of Object.keys(locked).sort(cmpBytes)) {
1697
+ const entry = locked[key]
1698
+ const pkg = entry.pkg ?? key
1699
+ if (!usableKey(key) || '' === pkg) {
1700
+ continue
1701
+ }
1702
+ const bases = basesFor(ctx, pkg)
1703
+ const list = await fetchList(ctx, bases, pkg)
1704
+ const advisory = await fetchAdvisory(ctx, bases, pkg)
1705
+ const newest = selectVersion(ctx, pkg, list, advisory, undefined, entry.v)
1706
+ const out: PkgOutdatedEntry = { key, v: entry.v, newest, moves: [] }
1707
+ if (null != advisory[entry.v]) {
1708
+ out.retracted = advisory[entry.v]
1709
+ }
1710
+ if (0 < versionCompare(newest, entry.v)) {
1711
+ out.moves = await movesWith(ctx, locked, key, pkg, newest)
1712
+ report.verdict = 'outdated'
1713
+ }
1714
+ else if (null != out.retracted) {
1715
+ report.verdict = 'outdated'
1716
+ }
1717
+ report.locked.push(out)
1718
+ }
1719
+ }
1720
+ catch (e) {
1721
+ report.events = ctx.events
1722
+ return refusedOutdated(report, e)
1723
+ }
1724
+ report.events = ctx.events
1725
+ return report
1726
+ }
1727
+
1728
+ // What a resolution taking `newest` for one key would move with it:
1729
+ // minimum version selection over the repository's manifests, from the
1730
+ // upgraded declaration down to the closure, against the lock.
1731
+ async function movesWith(ctx: AcquireCtx, locked: Record<string, LockEntry>, key: string,
1732
+ pkg: string, newest: string): Promise<string[]> {
1733
+ const selected: Record<string, string> = {}
1734
+ const targets: Record<string, string> = {}
1735
+ for (const k of Object.keys(locked)) {
1736
+ selected[k] = locked[k].v
1737
+ if (null != locked[k].pkg) {
1738
+ targets[k] = locked[k].pkg as string
1739
+ }
1740
+ }
1741
+ selected[key] = newest
1742
+ targets[key] = pkg
1743
+ let frontier = [key]
1744
+ for (let depth = 0; 0 < frontier.length; depth++) {
1745
+ if (LIMITS.depth <= depth) {
1746
+ refuse('module_depth', 'the closure under ' + pkg + ' nests past ' + LIMITS.depth, pkg)
1747
+ }
1748
+ if (LIMITS.closure < Object.keys(selected).length) {
1749
+ refuse('closure_too_large', 'the closure exceeds ' + LIMITS.closure + ' packages', pkg)
1750
+ }
1751
+ const next: string[] = []
1752
+ for (const k of frontier) {
1753
+ const target = targets[k] ?? k
1754
+ if (!usableKey(k) || (isAlias(k) && null == targets[k])) {
1755
+ continue
1756
+ }
1757
+ const top = await fetchManifest(ctx, basesFor(ctx, target), target, selected[k])
1758
+ const deps: Record<string, Dependency> = top.manifest.deps
1759
+ for (const dk of Object.keys(deps).sort(cmpBytes)) {
1760
+ if (null != deps[dk].pkg) {
1761
+ targets[dk] = deps[dk].pkg as string
1762
+ }
1763
+ if (null == selected[dk] || 0 > versionCompare(selected[dk], deps[dk].v)) {
1764
+ selected[dk] = deps[dk].v
1765
+ next.push(dk)
1766
+ }
1767
+ }
1768
+ }
1769
+ frontier = next
1770
+ }
1771
+ return Object.keys(selected).sort(cmpBytes)
1772
+ .filter((k) => k !== key && selected[k] !== locked[k]?.v)
1773
+ .map((k) => k + ' ' + (null == locked[k] ? 'unlocked' : locked[k].v) + ' -> ' + selected[k])
1774
+ }
1775
+
1776
+ function refusedOutdated(report: PkgOutdatedReport, e: unknown): PkgOutdatedReport {
1777
+ if (!(e instanceof PkgRefusal)) {
1778
+ throw e
1779
+ }
1780
+ report.verdict = 'refused'
1781
+ report.refusal = { code: e.code, message: e.message, ...(null == e.pkg ? {} : { pkg: e.pkg }) }
1782
+ return report
1783
+ }
1784
+
1785
+
1786
+ // THE LOCAL REGISTRY AND PROXY (`aontu pkg serve`): the directory
1787
+ // layout served verbatim; with upstreams, fetched on a miss and kept.
1788
+ const OBJECT_RE =
1789
+ /^\/(pkg\/[A-Za-z0-9!._/-]+\/@v\/(list|(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\.(zip|manifest|sig|sigstore\.json))|pkg\/[A-Za-z0-9!._/-]+\/@latest|advisory\/[A-Za-z0-9!._/-]+\.aon|tombstone\/(feed\.aon|[A-Za-z0-9!._/-]+\/@v\/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\.aon))$/
1790
+
1791
+ export function objectShape(p: string): boolean {
1792
+ return OBJECT_RE.test(p) && !p.split('/').some((e) => '' === e && p.indexOf('//') >= 0) &&
1793
+ !p.split('/').some((e) => '.' === e || '..' === e)
1794
+ }
1795
+
1796
+ export function objectMutable(p: string): boolean {
1797
+ return p.endsWith('/list') || p.endsWith('/@latest') || p.startsWith('/advisory/') ||
1798
+ p.startsWith('/tombstone/')
1799
+ }
1800
+
1801
+ function contentType(p: string): string {
1802
+ return p.endsWith('.zip') ? 'application/zip' :
1803
+ p.endsWith('.json') ? 'application/json' : 'text/plain; charset=utf-8'
1804
+ }
1805
+
1806
+ export type ServeOptions = {
1807
+ dir: string
1808
+ upstream: string[]
1809
+ listen: string
1810
+ http: PkgHttp
1811
+ }
1812
+
1813
+ export type Served = { url: string, close: () => Promise<void>, server: Server }
1814
+
1815
+ // One request: the shape gate, the directory, then the upstreams.
1816
+ export async function serveObject(opts: ServeOptions, p: string):
1817
+ Promise<{ status: number, body: Uint8Array, stale?: boolean }> {
1818
+ if (!objectShape(p)) {
1819
+ return { status: 404, body: utf8('not an object path\n') }
1820
+ }
1821
+ const file = pathJoin(opts.dir, ...p.split('/').filter((e) => '' !== e))
1822
+ const have = existsSync(file) && statSync(file).isFile()
1823
+ const mutable = objectMutable(p)
1824
+ if (have && !mutable) {
1825
+ return { status: 200, body: new Uint8Array(readFileSync(file)) }
1826
+ }
1827
+ for (const up of opts.upstream) {
1828
+ const r = await opts.http.get(up.replace(/\/$/, '') + p)
1829
+ if (200 === r.status) {
1830
+ mkdirSync(pathDirname(file), { recursive: true })
1831
+ writeFileSync(file, r.body)
1832
+ return { status: 200, body: r.body }
1833
+ }
1834
+ }
1835
+ if (have) {
1836
+ return { status: 200, body: new Uint8Array(readFileSync(file)), stale: 0 < opts.upstream.length }
1837
+ }
1838
+ return { status: 404, body: utf8('no such object\n') }
1839
+ }
1840
+
1841
+ export function startServe(opts: ServeOptions): Promise<Served> {
1842
+ const handler = (req: IncomingMessage, res: ServerResponse): void => {
1843
+ if ('GET' !== req.method && 'HEAD' !== req.method) {
1844
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
1845
+ res.end('the read path is GET\n')
1846
+ return
1847
+ }
1848
+ const p = new URL(req.url as string, 'http://localhost').pathname
1849
+ serveObject(opts, p).then((r) => {
1850
+ const headers: Record<string, string> = {
1851
+ 'content-type': 200 === r.status ? contentType(p) : 'text/plain; charset=utf-8',
1852
+ 'cache-control': objectMutable(p) ? 'max-age=60' : 'max-age=31536000, immutable',
1853
+ }
1854
+ if (true === r.stale) {
1855
+ headers['x-aontu-stale'] = 'no upstream answered; served from the cache'
1856
+ }
1857
+ res.writeHead(r.status, headers)
1858
+ res.end('HEAD' === req.method ? undefined : Buffer.from(r.body))
1859
+ })
1860
+ }
1861
+ const server = createServer(handler)
1862
+ const [host, port] = splitListen(opts.listen)
1863
+ return new Promise((resolveServed, reject) => {
1864
+ server.once('error', reject)
1865
+ server.listen(port, host, () => {
1866
+ const addr: any = server.address()
1867
+ resolveServed({
1868
+ url: servedUrl(addr.address, addr.port),
1869
+ server,
1870
+ close: () => new Promise((done) => server.close(() => done())),
1871
+ })
1872
+ })
1873
+ })
1874
+ }
1875
+
1876
+ // An IPv6 address is bracketed in a URL.
1877
+ export function servedUrl(address: string, port: number): string {
1878
+ return 'http://' + (address.includes(':') ? '[' + address + ']' : address) + ':' + port
1879
+ }
1880
+
1881
+ export function splitListen(listen: string): [string, number] {
1882
+ const bracketed = /^\[([^\]]*)\](?::(.*))?$/.exec(listen)
1883
+ if (null != bracketed) {
1884
+ const port = Number(bracketed[2])
1885
+ return [bracketed[1], undefined !== bracketed[2] && Number.isInteger(port) ? port : 8017]
1886
+ }
1887
+ const at = listen.lastIndexOf(':')
1888
+ if (0 > at) {
1889
+ return [listen, 8017]
1890
+ }
1891
+ const port = Number(listen.slice(at + 1))
1892
+ return [listen.slice(0, at), Number.isInteger(port) ? port : 8017]
1893
+ }
1894
+
1895
+
1896
+ // A body read no further than the archive cap: what comes back past
1897
+ // it is over the cap by construction, and every reader refuses it.
1898
+ export async function readBounded(r: Response, max: number): Promise<Uint8Array> {
1899
+ const chunks: Uint8Array[] = []
1900
+ let total = 0
1901
+ const reader = r.body?.getReader()
1902
+ for (; undefined !== reader && total <= max;) {
1903
+ const { done, value } = await reader.read()
1904
+ if (done) {
1905
+ break
1906
+ }
1907
+ chunks.push(value)
1908
+ total += value.length
1909
+ }
1910
+ if (undefined !== reader && total > max) {
1911
+ await reader.cancel()
1912
+ }
1913
+ return new Uint8Array(Buffer.concat(chunks))
1914
+ }
1915
+
1916
+ // THE ADAPTER: the platform's fetch, and the multipart a publish sends.
1917
+ export function defaultHttp(): PkgHttp {
1918
+ return {
1919
+ get: async (url: string) => {
1920
+ try {
1921
+ const r = await fetch(url, { redirect: 'manual' })
1922
+ return { status: r.status, body: await readBounded(r, ARCHIVE_LIMITS.bytes) }
1923
+ }
1924
+ catch {
1925
+ return { status: 0, body: new Uint8Array() }
1926
+ }
1927
+ },
1928
+ post: async (url: string, parts: PublishParts, token: string) => {
1929
+ const form = new FormData()
1930
+ form.set('manifest', new Blob([Buffer.from(parts.manifest)], { type: 'text/plain' }), 'manifest.aon')
1931
+ form.set('proof', new Blob([Buffer.from(parts.proof)], { type: 'text/plain' }), 'proof.aon')
1932
+ form.set('archive', new Blob([Buffer.from(parts.archive)], { type: 'application/zip' }), 'archive.zip')
1933
+ try {
1934
+ const r = await fetch(url, {
1935
+ method: 'POST', body: form,
1936
+ headers: '' === token ? {} : { authorization: 'Bearer ' + token },
1937
+ })
1938
+ return { status: r.status, body: new Uint8Array(await r.arrayBuffer()) }
1939
+ }
1940
+ catch {
1941
+ return { status: 0, body: new Uint8Array() }
1942
+ }
1943
+ },
1944
+ }
1945
+ }