@threadedstack/wmd-web 0.0.0-stage → 0.1.12

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 (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
@@ -0,0 +1,258 @@
1
+ // W71: pull an image in the page, with no pack host and no proxy.
2
+ //
3
+ // const { index, packs } = await pullImage('python:3.12-slim', { fetch, converter: wmdpackWasmUrl })
4
+ // bootWmd({ ..., channels: { services: [{ ..., layers: packs }] } })
5
+ //
6
+ // It does what the registry proxy does (web/registry/pull.ts): resolve the reference (an index:
7
+ // linux/arm64 first, else linux/amd64), check the config, download each layer (digest and size
8
+ // checked) and convert it with wmd's own converter (wmdpack.wasm: union.PackLayer for wasip1) over
9
+ // the packs below it, in a Worker. The result is the proxy's: an IMAGE_SCHEMA index (layers top
10
+ // first, each pack named by the sha256 of its bytes) and the packs in that order — the same bytes
11
+ // `wmd pull` and the proxy make for the same layers.
12
+ //
13
+ // Cache (optional): `store` with get(key) and put(key, bytes) (either may be async). Keys:
14
+ // 'sha256:<hex>' a pack's bytes (checked against its name when read back)
15
+ // 'chain:<hex>' a layer chain's record (JSON bytes {pack, size, layer}); the key is the
16
+ // proxy's chain key: the converter and every layer digest from the bottom up
17
+ // With a store, a layer whose chain is recorded and whose pack is there is not downloaded again.
18
+ // When the page is cross-origin isolated the packs are views of SharedArrayBuffers (the converter
19
+ // Worker and bootWmd use them in place, with no copy): a store that keeps them (IndexedDB, OPFS)
20
+ // copies them first (bytes.slice()).
21
+
22
+ import { createConverter } from './converter.js'
23
+ import { HubRepo, RegistryError, sha256 } from './hub.js'
24
+ import { isIndexType, isLayerType, isManifestType, isZstdLayer, MAX_LAYERS, pickPlatform } from './oci.js'
25
+ import { IMAGE_SCHEMA, isDigest, parsePlatform, parseRef, platformMatches, platformString } from './ref.js'
26
+
27
+ export { RegistryError }
28
+ export const CONVERTER = 'wmdpack/1 union.PackLayer'
29
+ export const DEFAULT_PLATFORMS = ['linux/arm64', 'linux/amd64']
30
+
31
+ const enc = new TextEncoder()
32
+ // WebCrypto does not hash views of shared memory
33
+ const unshared = b => (typeof SharedArrayBuffer !== 'undefined' && b.buffer instanceof SharedArrayBuffer ? b.slice() : b)
34
+ const hexOf = async s => (await sha256(enc.encode(s))).slice(7)
35
+
36
+ /** The proxy's chain keys (web/registry/store.ts chainKeys): one per layer, bottom first. */
37
+ export async function chainKeys(layerDigests) {
38
+ const keys = []
39
+ let prev = await hexOf(CONVERTER)
40
+ for (const d of layerDigests) {
41
+ prev = await hexOf(`${prev} ${d}`)
42
+ keys.push(prev)
43
+ }
44
+ return keys
45
+ }
46
+
47
+ async function fromStore(store, key) {
48
+ if (!store) return null
49
+ try {
50
+ const v = await store.get(key)
51
+ if (v == null) return null
52
+ return v instanceof Uint8Array ? v : new Uint8Array(v)
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {string} refStr an image reference (docker.io only)
60
+ * @param {object} o
61
+ * @param {typeof fetch} o.fetch the fetch to reach Docker Hub with (it must return 3xx for redirect: 'manual')
62
+ * @param {string|URL|Uint8Array|WebAssembly.Module|{convert: Function, add: Function}} o.converter wmdpack.wasm, or a converter from createConverter
63
+ * @param {string|string[]} [o.platform] platforms in order of preference (default linux/arm64, then linux/amd64)
64
+ * @param {(e: object) => void} [o.onProgress] the proxy's pull events: resolved, layer, progress
65
+ * @param {AbortSignal} [o.signal]
66
+ * @param {{get: (key: string) => any, put: (key: string, bytes: Uint8Array) => any}} [o.store]
67
+ * @param {number} [o.concurrency] downloads at once (default 3)
68
+ * @param {string[]} [o.local] test registry host:port names allowed besides Docker Hub
69
+ * @returns {Promise<{index: object, packs: Uint8Array[]}>} packs top first, as index.layers
70
+ */
71
+ export async function pullImage(refStr, o = {}) {
72
+ const t0 = performance.now()
73
+ const ref = parseRef(refStr)
74
+ const wants = (Array.isArray(o.platform) ? o.platform : o.platform ? [o.platform] : DEFAULT_PLATFORMS).map(p => parsePlatform(p))
75
+ const ev = o.onProgress || (() => {})
76
+ const sig = o.signal
77
+ const hub = new HubRepo(ref, { fetch: o.fetch, local: o.local })
78
+ if (!o.converter) throw new TypeError('pullImage: pass converter: wmdpack.wasm (a URL, its bytes or a WebAssembly.Module)')
79
+
80
+ // 1. resolve
81
+ let man = await hub.manifest(ref.digest || ref.tag, ref.digest, { signal: sig })
82
+ let indexDigest = null
83
+ let want = null
84
+ for (let depth = 0; ; depth++) {
85
+ const looksIndex = isIndexType(man.mediaType || '') || (!man.mediaType && Array.isArray(man.doc.manifests) && !man.doc.layers)
86
+ if (!looksIndex) break
87
+ if (depth >= 2) throw new RegistryError(`${ref.ref}: indexes nested more than one level are not supported`)
88
+ indexDigest ??= man.digest
89
+ const pick = pickPlatform(man.doc, want ? [want] : wants)
90
+ if (!pick.desc) {
91
+ const have = pick.have.length ? pick.have.join(', ') : 'no platforms'
92
+ throw new RegistryError(`${ref.ref}: no ${wants.map(platformString).join(' or ')} image in its index (it has: ${have})`, 404)
93
+ }
94
+ want = pick.platform ?? want
95
+ man = await hub.manifest(pick.desc.digest, pick.desc.digest, { signal: sig })
96
+ }
97
+ if (man.doc.schemaVersion === 1 || !(isManifestType(man.mediaType || '') || (!man.mediaType && Array.isArray(man.doc.layers)))) {
98
+ throw new RegistryError(`${ref.ref}: unsupported manifest type ${JSON.stringify(man.mediaType || `schema ${man.doc.schemaVersion}`)}`)
99
+ }
100
+ const layers = man.doc.layers
101
+ if (!Array.isArray(layers) || layers.length === 0) throw new RegistryError(`${ref.ref}: the manifest has no layers`)
102
+ if (layers.length > MAX_LAYERS) throw new RegistryError(`${ref.ref}: ${layers.length} layers (at most ${MAX_LAYERS})`)
103
+ for (const l of layers) {
104
+ if (!isDigest(l?.digest) || !Number.isSafeInteger(l.size) || l.size < 0) throw new RegistryError(`${ref.ref}: bad layer descriptor ${JSON.stringify(l).slice(0, 200)}`)
105
+ if (isZstdLayer(l.mediaType)) throw new RegistryError(`${ref.ref}: layer ${l.digest.slice(0, 19)} is zstd; the page's converter takes tar and gzip layers only`)
106
+ if (!isLayerType(l.mediaType || '', { zstd: false })) throw new RegistryError(`${ref.ref}: layer ${l.digest.slice(0, 19)} has media type ${l.mediaType} (foreign or not a filesystem tar)`)
107
+ }
108
+
109
+ // 2. config (the platform is checked against it too: an index entry can lie)
110
+ const cfgDesc = man.doc.config
111
+ if (!cfgDesc || !isDigest(cfgDesc.digest)) throw new RegistryError(`${ref.ref}: the manifest has no config`)
112
+ const cfgBytes = await hub.blob(cfgDesc, { signal: sig, max: 4 << 20 })
113
+ let cfg
114
+ try {
115
+ cfg = JSON.parse(new TextDecoder().decode(cfgBytes))
116
+ } catch {
117
+ throw new RegistryError(`${ref.ref}: the config is not JSON`)
118
+ }
119
+ const cfgPlat = cfg.os && cfg.architecture ? { os: cfg.os, architecture: cfg.architecture, variant: cfg.variant || '' } : null
120
+ if (!want) want = cfgPlat ? wants.find(w => platformMatches(w, cfgPlat)) : wants[0]
121
+ if (!want || (cfgPlat && !platformMatches(want, cfgPlat))) {
122
+ throw new RegistryError(`${ref.ref}: the image is ${cfgPlat ? platformString(cfgPlat) : 'of no platform'}, not ${wants.map(platformString).join(' or ')}`, 404)
123
+ }
124
+ const total = layers.reduce((a, l) => a + l.size, 0)
125
+ ev({ t: 'resolved', ref: ref.ref, platform: platformString(want), manifest: man.digest, index: indexDigest, layers: layers.length, bytes: total })
126
+
127
+ // 3. layers: cached ones from the store, the rest downloaded (a few at once, in order) and converted in order
128
+ const keys = await chainKeys(layers.map(l => l.digest))
129
+ const cached = []
130
+ for (let i = 0; i < layers.length; i++) {
131
+ let rec = null
132
+ const raw = await fromStore(o.store, `chain:${keys[i]}`)
133
+ if (raw) {
134
+ try {
135
+ const r = JSON.parse(new TextDecoder().decode(raw))
136
+ if (isDigest(r.pack) && r.layer === layers[i].digest) {
137
+ const bytes = await fromStore(o.store, r.pack)
138
+ if (bytes && bytes.length === r.size && (await sha256(unshared(bytes))) === r.pack) rec = { pack: r.pack, size: r.size, bytes }
139
+ }
140
+ } catch {}
141
+ }
142
+ cached.push(rec)
143
+ }
144
+
145
+ const abort = new AbortController()
146
+ const inner = sig ? AbortSignal.any([sig, abort.signal]) : abort.signal
147
+ const todo = layers.map((_, i) => i).filter(i => !cached[i])
148
+ const ownConverter = !(typeof o.converter?.convert === 'function')
149
+ let conv = null
150
+ let got = 0
151
+ let downloadBytes = 0
152
+ const tDl0 = performance.now()
153
+ let tDlEnd = tDl0
154
+ const downloads = layers.map(() => null)
155
+ const limit = Math.max(1, o.concurrency ?? 3)
156
+ let next = 0
157
+ let running = 0
158
+ const startNext = () => {
159
+ while (running < limit && next < todo.length) {
160
+ const i = todo[next++]
161
+ running++
162
+ const l = layers[i]
163
+ const t = performance.now()
164
+ ev({ t: 'layer', i, state: 'downloading', digest: l.digest, bytes: l.size })
165
+ let mine = 0
166
+ const p = hub
167
+ .blob(l, {
168
+ signal: inner,
169
+ onBytes: n => {
170
+ got += n
171
+ mine += n
172
+ ev({ t: 'progress', bytes: got, total, layer: i, layerBytes: mine, layerTotal: l.size })
173
+ },
174
+ })
175
+ .then(b => {
176
+ downloadBytes += b.length
177
+ tDlEnd = performance.now()
178
+ ev({ t: 'layer', i, state: 'downloaded', digest: l.digest, bytes: b.length, ms: Math.round(performance.now() - t) })
179
+ return b
180
+ })
181
+ .finally(() => {
182
+ running--
183
+ startNext()
184
+ })
185
+ p.catch(() => {})
186
+ downloads[i] = p
187
+ }
188
+ }
189
+
190
+ const out = [] // bottom first: {pack, size, layer, bytes, cached}
191
+ let convertMs = 0
192
+ let fileBytes = 0
193
+ let wasmBytes = 0
194
+ try {
195
+ startNext()
196
+ if (todo.length) conv = ownConverter ? await createConverter(o.converter) : o.converter
197
+ for (let i = 0; i < layers.length; i++) {
198
+ inner.throwIfAborted()
199
+ const l = layers[i]
200
+ if (cached[i]) {
201
+ const c = cached[i]
202
+ out.push({ pack: c.pack, size: c.size, layer: l.digest, bytes: c.bytes, cached: true })
203
+ // a later layer converts over it
204
+ if (todo.some(j => j > i)) await conv.add(c.pack, c.bytes)
205
+ ev({ t: 'layer', i, state: 'cached', digest: l.digest, pack: c.pack, packBytes: c.size })
206
+ continue
207
+ }
208
+ const blob = await downloads[i]
209
+ downloads[i] = null
210
+ const below = out.slice(0, i).map(x => x.pack).reverse()
211
+ const r = await conv.convert(blob, below)
212
+ convertMs += r.ms
213
+ fileBytes += r.fileBytes
214
+ wasmBytes = Math.max(wasmBytes, r.wasmBytes || 0)
215
+ out.push({ pack: r.sha, size: r.size, layer: l.digest, bytes: r.bytes, cached: false })
216
+ if (o.store) {
217
+ await o.store.put(r.sha, r.bytes)
218
+ await o.store.put(`chain:${keys[i]}`, enc.encode(JSON.stringify({ pack: r.sha, size: r.size, layer: l.digest })))
219
+ }
220
+ ev({ t: 'layer', i, state: 'converted', digest: l.digest, pack: r.sha, packBytes: r.size, ms: r.ms })
221
+ }
222
+ } catch (e) {
223
+ abort.abort(e)
224
+ await Promise.allSettled(downloads.filter(Boolean))
225
+ throw e
226
+ } finally {
227
+ if (conv && ownConverter) conv.close()
228
+ }
229
+
230
+ // 4. the index (top first, as the Overlay takes the packs): the proxy's document
231
+ const c = cfg.config || {}
232
+ const top = out.slice().reverse()
233
+ const index = {
234
+ schema: IMAGE_SCHEMA,
235
+ ref: ref.ref,
236
+ platform: platformString(want),
237
+ index: indexDigest,
238
+ manifest: man.digest,
239
+ os: cfg.os || null,
240
+ architecture: cfg.architecture || null,
241
+ config: { Env: c.Env || null, Cmd: c.Cmd || null, Entrypoint: c.Entrypoint || null, WorkingDir: c.WorkingDir || '', User: c.User || '', ExposedPorts: c.ExposedPorts || null },
242
+ layers: top.map(x => ({ pack: x.pack, size: x.size, layer: x.layer })),
243
+ converter: CONVERTER,
244
+ pulled: new Date().toISOString(),
245
+ stats: {
246
+ totalMs: Math.round(performance.now() - t0),
247
+ layerBytes: total,
248
+ downloadBytes,
249
+ downloadMs: todo.length ? Math.round(tDlEnd - tDl0) : 0,
250
+ convertMs,
251
+ fileBytes,
252
+ wasmBytes, // the converter's largest wasm memory, one layer's
253
+ packBytes: out.reduce((a, x) => a + x.size, 0),
254
+ cachedLayers: out.filter(x => x.cached).length,
255
+ },
256
+ }
257
+ return { index, packs: top.map(x => x.bytes) }
258
+ }
@@ -0,0 +1,110 @@
1
+ // P-B7: image references, platforms and the static pack host's layout. Plain JS with no
2
+ // imports, so the page, the service worker's page and the Bun proxy share it.
3
+ //
4
+ // The static host layout (content addressed, immutable URLs, one small index per
5
+ // image@platform):
6
+ //
7
+ // /blobs/sha256/<hex> a pack (wmd's pack format), named by the sha256 of
8
+ // its own bytes; never changes (Cache-Control immutable)
9
+ // /idx/<registry>/<repo>/<tag>/<os>-<arch>[-<variant>].json
10
+ // the image's index for one platform (IMAGE_SCHEMA):
11
+ // what a tag points at now; revalidated on every visit
12
+ //
13
+ // Any static file server can serve a folder in this layout (with the page's COOP/COEP and
14
+ // same-origin CORP); the registry proxy (web/registry/) writes the same layout as its cache.
15
+
16
+ export const IMAGE_SCHEMA = 'wmd-web-image/1'
17
+ export const DOCKER_HUB = 'docker.io'
18
+ export const DEFAULT_PLATFORM = 'linux/arm64'
19
+
20
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/
21
+ const TAG_RE = /^[\w][\w.-]{0,127}$/
22
+ const COMPONENT_RE = /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*$/
23
+ const HOST_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]{0,253}[a-zA-Z0-9])?(?::\d{1,5})?$/
24
+
25
+ export const isDigest = d => typeof d === 'string' && DIGEST_RE.test(d)
26
+
27
+ /**
28
+ * Parses an image reference the way docker does: `busybox` is docker.io/library/busybox:latest,
29
+ * a first component with a dot, a colon or "localhost" is a registry host.
30
+ * @returns {{registry:string, repo:string, tag:string|null, digest:string|null, name:string, ref:string}}
31
+ */
32
+ export function parseRef(s) {
33
+ if (typeof s !== 'string' || !s || s.length > 512) throw new Error(`bad image reference ${JSON.stringify(s)}`)
34
+ let rest = s
35
+ let digest = null
36
+ const at = rest.indexOf('@')
37
+ if (at >= 0) {
38
+ digest = rest.slice(at + 1)
39
+ rest = rest.slice(0, at)
40
+ if (!isDigest(digest)) throw new Error(`bad digest in ${JSON.stringify(s)} (only sha256:<64 hex> is accepted)`)
41
+ }
42
+ let tag = null
43
+ const slash = rest.lastIndexOf('/')
44
+ const colon = rest.lastIndexOf(':')
45
+ if (colon > slash) {
46
+ tag = rest.slice(colon + 1)
47
+ rest = rest.slice(0, colon)
48
+ if (!TAG_RE.test(tag)) throw new Error(`bad tag in ${JSON.stringify(s)}`)
49
+ }
50
+ const parts = rest.split('/')
51
+ let registry = DOCKER_HUB
52
+ if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':') || parts[0] === 'localhost')) {
53
+ registry = parts.shift()
54
+ if (!HOST_RE.test(registry)) throw new Error(`bad registry host in ${JSON.stringify(s)}`)
55
+ }
56
+ if (registry === 'index.docker.io' || registry === 'registry-1.docker.io') registry = DOCKER_HUB
57
+ if (registry === DOCKER_HUB && parts.length === 1) parts.unshift('library')
58
+ if (!parts.length || !parts.every(p => COMPONENT_RE.test(p))) throw new Error(`bad repository name in ${JSON.stringify(s)}`)
59
+ const repo = parts.join('/')
60
+ if (!tag && !digest) tag = 'latest'
61
+ const name = `${registry}/${repo}`
62
+ return { registry, repo, tag, digest, name, ref: `${name}${tag ? `:${tag}` : ''}${digest ? `@${digest}` : ''}` }
63
+ }
64
+
65
+ /** "linux/arm64/v8" -> {os, architecture, variant}. */
66
+ export function parsePlatform(p = DEFAULT_PLATFORM) {
67
+ const [os, architecture, variant = '', extra] = String(p).split('/')
68
+ if (!os || !architecture || extra !== undefined || ![os, architecture, variant].every(x => x === '' || /^[a-z0-9_]+$/.test(x))) {
69
+ throw new Error(`bad platform ${JSON.stringify(p)} (want os/arch[/variant])`)
70
+ }
71
+ return { os, architecture, variant: normVariant(architecture, variant) }
72
+ }
73
+
74
+ /** arm64's only variant is v8: "linux/arm64" and "linux/arm64/v8" are the same platform. */
75
+ function normVariant(arch, v) {
76
+ if (arch === 'arm64' && (v === '' || v === 'v8')) return ''
77
+ return v || ''
78
+ }
79
+
80
+ export const platformString = p => `${p.os}/${p.architecture}${normVariant(p.architecture, p.variant || '') ? `/${p.variant}` : ''}`
81
+
82
+ /** Whether an index entry's platform runs a request for `want` (exact os and arch; variant equal once normalized). */
83
+ export function platformMatches(want, have) {
84
+ if (!have) return false
85
+ return have.os === want.os && have.architecture === want.architecture && normVariant(have.architecture, have.variant || '') === want.variant
86
+ }
87
+
88
+ /** The index path for an image@platform (the tag, or "@sha256-<hex>" for a digest reference). */
89
+ export function indexPath(ref, platform = DEFAULT_PLATFORM) {
90
+ const r = typeof ref === 'string' ? parseRef(ref) : ref
91
+ const p = typeof platform === 'string' ? parsePlatform(platform) : platform
92
+ const at = r.digest ? `@${r.digest.replace(':', '-')}` : r.tag
93
+ return `idx/${r.registry.replace(':', '_')}/${r.repo}/${at}/${p.os}-${p.architecture}${p.variant ? `-${p.variant}` : ''}.json`
94
+ }
95
+
96
+ export const blobPath = digest => {
97
+ if (!isDigest(digest)) throw new Error(`bad digest ${JSON.stringify(digest)}`)
98
+ return `blobs/sha256/${digest.slice(7)}`
99
+ }
100
+
101
+ /** Checks an image index document (from the host or the proxy) before the page trusts it. */
102
+ export function checkImageIndex(x) {
103
+ if (!x || x.schema !== IMAGE_SCHEMA) throw new Error(`not a ${IMAGE_SCHEMA} index`)
104
+ if (!Array.isArray(x.layers) || x.layers.length === 0 || x.layers.length > 256) throw new Error('index: bad layer list')
105
+ for (const l of x.layers) {
106
+ if (!isDigest(l.pack) || !Number.isSafeInteger(l.size) || l.size < 16) throw new Error(`index: bad layer entry ${JSON.stringify(l)}`)
107
+ }
108
+ if (x.config !== null && typeof x.config !== 'object') throw new Error('index: bad config')
109
+ return x
110
+ }