@threadedstack/wmd-web 0.0.0-stage → 0.1.13
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.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- 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
|
+
}
|