@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,68 @@
|
|
|
1
|
+
// W71: the page's side of pack-worker.js: one Worker that converts layers to packs with wmdpack.wasm.
|
|
2
|
+
//
|
|
3
|
+
// const conv = await createConverter(wmdpackWasm) // a URL, bytes or a WebAssembly.Module
|
|
4
|
+
// const p = await conv.convert(layerBytes, belowShas) // {sha, size, bytes, ms, fileBytes, wasmBytes}
|
|
5
|
+
// await conv.add(sha, bytes) // a pack the page already has, for later BELOWs
|
|
6
|
+
// conv.close()
|
|
7
|
+
|
|
8
|
+
let seq = 0
|
|
9
|
+
|
|
10
|
+
async function toModule(src, fetchFn) {
|
|
11
|
+
if (src instanceof WebAssembly.Module) return src
|
|
12
|
+
if (src instanceof Uint8Array || src instanceof ArrayBuffer) return WebAssembly.compile(src)
|
|
13
|
+
if (typeof src === 'string' || src instanceof URL) {
|
|
14
|
+
const r = await fetchFn(src)
|
|
15
|
+
if (!r.ok) throw new Error(`wmdpack.wasm: ${src}: HTTP ${r.status}`)
|
|
16
|
+
return WebAssembly.compile(await r.arrayBuffer())
|
|
17
|
+
}
|
|
18
|
+
throw new TypeError('createConverter: wmdpack.wasm as a URL, bytes or a WebAssembly.Module')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string|URL|Uint8Array|ArrayBuffer|WebAssembly.Module} wasm wmdpack.wasm (`make wmdpack-wasm`)
|
|
23
|
+
* @param {{maxFileBytes?: number, fetch?: typeof fetch}} [o] maxFileBytes: the largest pack (default 4 GiB)
|
|
24
|
+
*/
|
|
25
|
+
export async function createConverter(wasm, { maxFileBytes, fetch: fetchFn = globalThis.fetch } = {}) {
|
|
26
|
+
const module = await toModule(wasm, fetchFn)
|
|
27
|
+
const worker = new Worker(new URL('./pack-worker.js', import.meta.url), { type: 'module', name: 'wmdpack' })
|
|
28
|
+
const waiting = new Map()
|
|
29
|
+
let broken = null
|
|
30
|
+
const failAll = err => {
|
|
31
|
+
broken = err
|
|
32
|
+
for (const w of waiting.values()) w.reject(err)
|
|
33
|
+
waiting.clear()
|
|
34
|
+
}
|
|
35
|
+
worker.onmessage = ({ data: m }) => {
|
|
36
|
+
const w = waiting.get(m.id)
|
|
37
|
+
if (!w) return
|
|
38
|
+
waiting.delete(m.id)
|
|
39
|
+
if (m.t === 'error') w.reject(new Error(m.message))
|
|
40
|
+
else w.resolve(m)
|
|
41
|
+
}
|
|
42
|
+
worker.onerror = e => failAll(new Error(`the converter Worker failed: ${e.message || 'error'}`))
|
|
43
|
+
worker.postMessage({ t: 'init', module, maxFileBytes })
|
|
44
|
+
const call = (msg, transfer = []) => {
|
|
45
|
+
if (broken) return Promise.reject(broken)
|
|
46
|
+
const id = ++seq
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
waiting.set(id, { resolve, reject })
|
|
49
|
+
worker.postMessage({ ...msg, id }, transfer)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
/** Converts one layer (tar or gzip; its bytes are moved to the Worker) over the packs below it, top first. */
|
|
54
|
+
async convert(layer, below = []) {
|
|
55
|
+
const buf = layer instanceof ArrayBuffer ? layer : layer.byteOffset === 0 && layer.byteLength === layer.buffer.byteLength ? layer.buffer : layer.slice().buffer
|
|
56
|
+
const m = await call({ t: 'convert', layer: buf, below }, [buf])
|
|
57
|
+
return { sha: m.sha, size: m.size, bytes: m.bytes, ms: m.ms, fileBytes: m.fileBytes, wasmBytes: m.wasmBytes }
|
|
58
|
+
},
|
|
59
|
+
/** A pack the page already has (from its store), so a later layer can convert over it. */
|
|
60
|
+
async add(sha, bytes) {
|
|
61
|
+
await call({ t: 'add', sha, bytes })
|
|
62
|
+
},
|
|
63
|
+
close() {
|
|
64
|
+
failAll(new Error('the converter is closed'))
|
|
65
|
+
worker.terminate()
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// W71: Docker Hub from the page, over a fetch the caller injects (in wmn's hosted editor,
|
|
2
|
+
// createRelayFetch: TLS in the page, the bytes through a wisp relay). The same rules as the
|
|
3
|
+
// registry proxy (web/registry/upstream.ts):
|
|
4
|
+
//
|
|
5
|
+
// - only Docker Hub's registry and auth hosts, and its blob CDN hosts for a blob GET reached by
|
|
6
|
+
// a redirect, are contacted (plus `local` host:port names, for a test registry);
|
|
7
|
+
// - redirects are followed by hand, and the token is never sent to another host;
|
|
8
|
+
// - anonymous bearer tokens only (a private image is refused);
|
|
9
|
+
// - every manifest and blob is checked against its digest and size, with size caps.
|
|
10
|
+
//
|
|
11
|
+
// The fetch must answer `redirect: 'manual'` with the 3xx response itself (createRelayFetch and
|
|
12
|
+
// Bun do; a browser's own fetch gives an opaque redirect, which is refused here).
|
|
13
|
+
|
|
14
|
+
import { ACCEPT_MANIFEST, hostAllowed, MAX_MANIFEST_BYTES, parseChallenge } from './oci.js'
|
|
15
|
+
import { DOCKER_HUB, isDigest } from './ref.js'
|
|
16
|
+
|
|
17
|
+
export const MAX_BLOB_BYTES = 2 * 2 ** 30 // one layer in the page's memory
|
|
18
|
+
|
|
19
|
+
export class RegistryError extends Error {
|
|
20
|
+
constructor(message, status = 502) {
|
|
21
|
+
super(message)
|
|
22
|
+
this.name = 'RegistryError'
|
|
23
|
+
this.status = status
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
28
|
+
export const sha256 = async bytes => `sha256:${hex(await crypto.subtle.digest('SHA-256', bytes))}`
|
|
29
|
+
|
|
30
|
+
const timeoutSignal = (signal, ms) => {
|
|
31
|
+
const sigs = [signal, ms ? AbortSignal.timeout(ms) : null].filter(Boolean)
|
|
32
|
+
return sigs.length > 1 ? AbortSignal.any(sigs) : sigs[0]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The body, read into one buffer of at most `max` bytes (`exact`: it must be exactly that many).
|
|
37
|
+
* A lying or missing Content-Length cannot make it bigger.
|
|
38
|
+
*/
|
|
39
|
+
async function readBody(res, max, what, { exact = false, onBytes, idleMs = 0 } = {}) {
|
|
40
|
+
const out = new Uint8Array(exact ? max : Math.min(max, Number(res.headers.get('content-length')) || 64 << 10))
|
|
41
|
+
let buf = out
|
|
42
|
+
let n = 0
|
|
43
|
+
const reader = res.body?.getReader()
|
|
44
|
+
if (!reader) {
|
|
45
|
+
const b = new Uint8Array(await res.arrayBuffer())
|
|
46
|
+
if (b.length > max) throw new RegistryError(`${what}: more than ${max} bytes, refused`)
|
|
47
|
+
return b
|
|
48
|
+
}
|
|
49
|
+
let timer = null
|
|
50
|
+
const arm = () => {
|
|
51
|
+
if (!idleMs) return
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
timer = setTimeout(() => reader.cancel(new Error(`no bytes for ${idleMs} ms`)).catch(() => {}), idleMs)
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
arm()
|
|
57
|
+
for (;;) {
|
|
58
|
+
const { done, value } = await reader.read()
|
|
59
|
+
if (done) break
|
|
60
|
+
arm()
|
|
61
|
+
if (n + value.length > max) {
|
|
62
|
+
reader.cancel().catch(() => {})
|
|
63
|
+
throw new RegistryError(`${what}: more than ${max} bytes, refused`)
|
|
64
|
+
}
|
|
65
|
+
if (n + value.length > buf.length) {
|
|
66
|
+
const g = new Uint8Array(Math.min(max, Math.max(buf.length * 2, n + value.length)))
|
|
67
|
+
g.set(buf.subarray(0, n))
|
|
68
|
+
buf = g
|
|
69
|
+
}
|
|
70
|
+
buf.set(value, n)
|
|
71
|
+
n += value.length
|
|
72
|
+
onBytes?.(value.length)
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer)
|
|
76
|
+
}
|
|
77
|
+
return n === buf.length ? buf : buf.slice(0, n)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One repository on Docker Hub (or a `local` test registry), with its anonymous pull token. */
|
|
81
|
+
export class HubRepo {
|
|
82
|
+
/**
|
|
83
|
+
* @param {{registry: string, repo: string}} ref a parsed reference (ref.js parseRef)
|
|
84
|
+
* @param {{fetch: typeof fetch, local?: string[]}} o
|
|
85
|
+
*/
|
|
86
|
+
constructor(ref, { fetch: fetchFn, local = [] }) {
|
|
87
|
+
if (typeof fetchFn !== 'function') throw new TypeError('pullImage: pass a fetch (createRelayFetch in the hosted editor)')
|
|
88
|
+
this.fetch = fetchFn
|
|
89
|
+
this.local = local
|
|
90
|
+
this.repo = ref.repo
|
|
91
|
+
if (ref.registry === DOCKER_HUB) this.base = 'https://registry-1.docker.io'
|
|
92
|
+
else if (local.includes(ref.registry)) this.base = `http://${ref.registry}`
|
|
93
|
+
else throw new RegistryError(`registry ${ref.registry} is not allowed: the page pulls from Docker Hub (docker.io) only`, 403)
|
|
94
|
+
this.token = null
|
|
95
|
+
this.stats = { requests: 0, tokenFetches: 0, redirects: 0 }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async #fetch(url, accept, auth, o, blobCdn = false) {
|
|
99
|
+
if (!hostAllowed(url, this.local, blobCdn)) throw new RegistryError(`refusing to connect to ${url.host}: not a Docker Hub host`, 403)
|
|
100
|
+
const headers = {}
|
|
101
|
+
if (accept) headers.Accept = accept
|
|
102
|
+
if (auth && this.token) headers.Authorization = `Bearer ${this.token}`
|
|
103
|
+
this.stats.requests++
|
|
104
|
+
let res
|
|
105
|
+
try {
|
|
106
|
+
res = await this.fetch(url.href, { headers, redirect: 'manual', signal: o.signal })
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (o.signal?.aborted) throw o.signal.reason ?? e
|
|
109
|
+
throw new RegistryError(`${url.host}: ${e?.name === 'TimeoutError' ? 'timed out' : e?.message || e}`)
|
|
110
|
+
}
|
|
111
|
+
if (res.type === 'opaqueredirect') throw new RegistryError(`${url.host}: the fetch hid a redirect (it must answer redirect: 'manual' with the 3xx response)`)
|
|
112
|
+
return res
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** GET with the token, the challenge answered once, redirects followed by hand (no auth header to another host). */
|
|
116
|
+
async get(path, accept, o = {}) {
|
|
117
|
+
const url = new URL(`${this.base}/v2/${this.repo}/${path}`)
|
|
118
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
119
|
+
let res = await this.#fetch(url, accept, true, o)
|
|
120
|
+
let cur = url
|
|
121
|
+
let hops = 0
|
|
122
|
+
while (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
123
|
+
if (++hops > 5) throw new RegistryError(`${url.pathname}: too many redirects`)
|
|
124
|
+
res.body?.cancel().catch(() => {})
|
|
125
|
+
const next = new URL(res.headers.get('location'), cur)
|
|
126
|
+
this.stats.redirects++
|
|
127
|
+
res = await this.#fetch(next, accept, next.host === url.host, o, path.startsWith('blobs/'))
|
|
128
|
+
cur = next
|
|
129
|
+
}
|
|
130
|
+
if (res.status === 401 && attempt === 0 && cur === url) {
|
|
131
|
+
const ch = parseChallenge(res.headers.get('www-authenticate') || '')
|
|
132
|
+
res.body?.cancel().catch(() => {})
|
|
133
|
+
if (ch.scheme !== 'bearer') throw new RegistryError(`${this.repo}: the registry wants ${ch.scheme || 'unknown'} auth; only anonymous bearer tokens are supported`, 401)
|
|
134
|
+
await this.#fetchToken(ch.params, o)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
return res
|
|
138
|
+
}
|
|
139
|
+
throw new RegistryError(`${this.repo}: unauthorized (anonymous pulls only; a private image is not served)`, 401)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #fetchToken(p, o) {
|
|
143
|
+
if (!p.realm) throw new RegistryError('auth challenge without a realm')
|
|
144
|
+
let u
|
|
145
|
+
try {
|
|
146
|
+
u = new URL(p.realm)
|
|
147
|
+
} catch {
|
|
148
|
+
throw new RegistryError(`bad token realm ${JSON.stringify(p.realm)}`)
|
|
149
|
+
}
|
|
150
|
+
if (!hostAllowed(u, this.local)) throw new RegistryError(`refusing token realm ${u.host}: not a Docker Hub host`, 403)
|
|
151
|
+
if (p.service) u.searchParams.set('service', p.service)
|
|
152
|
+
u.searchParams.set('scope', p.scope || `repository:${this.repo}:pull`)
|
|
153
|
+
this.stats.tokenFetches++
|
|
154
|
+
const res = await this.#fetch(u, 'application/json', false, { signal: timeoutSignal(o.signal, 30_000) })
|
|
155
|
+
if (!res.ok) throw new RegistryError(`token from ${u.host}: HTTP ${res.status}`, res.status === 401 ? 401 : 502)
|
|
156
|
+
let j = {}
|
|
157
|
+
try {
|
|
158
|
+
j = JSON.parse(new TextDecoder().decode(await readBody(res, MAX_MANIFEST_BYTES, `token from ${u.host}`)))
|
|
159
|
+
} catch (e) {
|
|
160
|
+
if (e instanceof RegistryError) throw e
|
|
161
|
+
}
|
|
162
|
+
const t = j.token || j.access_token
|
|
163
|
+
if (typeof t !== 'string' || !t) throw new RegistryError(`token from ${u.host}: no token in the reply`)
|
|
164
|
+
this.token = t
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A manifest or index: {body, doc, mediaType, digest}. With `want` (a digest), bytes that hash otherwise are refused. */
|
|
168
|
+
async manifest(reference, want = null, o = {}) {
|
|
169
|
+
const res = await this.get(`manifests/${reference}`, ACCEPT_MANIFEST, { signal: timeoutSignal(o.signal, 60_000) })
|
|
170
|
+
if (res.status === 404) {
|
|
171
|
+
res.body?.cancel().catch(() => {})
|
|
172
|
+
throw new RegistryError(`${this.repo}:${reference}: not found (or private)`, 404)
|
|
173
|
+
}
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
res.body?.cancel().catch(() => {})
|
|
176
|
+
throw new RegistryError(`${this.repo} manifest ${reference}: HTTP ${res.status}`, res.status === 429 ? 429 : 502)
|
|
177
|
+
}
|
|
178
|
+
const what = `${this.repo} manifest ${reference}`
|
|
179
|
+
const body = await readBody(res, MAX_MANIFEST_BYTES, what).catch(e => {
|
|
180
|
+
throw e instanceof RegistryError ? e : new RegistryError(`${what}: the transfer broke: ${e?.message || e}`)
|
|
181
|
+
})
|
|
182
|
+
const digest = await sha256(body)
|
|
183
|
+
const expect = want || (isDigest(reference) ? reference : null)
|
|
184
|
+
if (expect && digest !== expect) throw new RegistryError(`${this.repo} manifest: the registry sent bytes with digest ${digest}, not ${expect}; refused`)
|
|
185
|
+
let doc
|
|
186
|
+
try {
|
|
187
|
+
doc = JSON.parse(new TextDecoder().decode(body))
|
|
188
|
+
} catch {
|
|
189
|
+
throw new RegistryError(`${what}: not JSON`)
|
|
190
|
+
}
|
|
191
|
+
if (!doc || typeof doc !== 'object') throw new RegistryError(`${what}: not an object`)
|
|
192
|
+
const mediaType = doc.mediaType || (res.headers.get('content-type') || '').split(';')[0].trim()
|
|
193
|
+
return { body, doc, mediaType, digest }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A blob into memory, checked against its digest and size.
|
|
198
|
+
* @param {{digest: string, size: number}} desc
|
|
199
|
+
* @param {{signal?: AbortSignal, onBytes?: (n: number) => void, max?: number, idleMs?: number}} [o]
|
|
200
|
+
*/
|
|
201
|
+
async blob(desc, o = {}) {
|
|
202
|
+
const max = o.max ?? MAX_BLOB_BYTES
|
|
203
|
+
const short = desc.digest?.slice?.(0, 19)
|
|
204
|
+
if (!isDigest(desc.digest)) throw new RegistryError(`bad digest ${desc.digest}`)
|
|
205
|
+
if (!(Number.isSafeInteger(desc.size) && desc.size >= 0 && desc.size <= max)) throw new RegistryError(`blob ${short}: ${desc.size} bytes refused (at most ${max})`)
|
|
206
|
+
const res = await this.get(`blobs/${desc.digest}`, null, { signal: o.signal })
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
res.body?.cancel().catch(() => {})
|
|
209
|
+
throw new RegistryError(`${this.repo} blob ${short}: HTTP ${res.status}`)
|
|
210
|
+
}
|
|
211
|
+
const idleMs = o.idleMs ?? 60_000
|
|
212
|
+
const body = await readBody(res, desc.size, `blob ${short}`, { exact: true, onBytes: o.onBytes, idleMs }).catch(e => {
|
|
213
|
+
if (o.signal?.aborted) throw o.signal.reason ?? e
|
|
214
|
+
throw e instanceof RegistryError ? e : new RegistryError(`blob ${short}: download failed: ${e?.message || e}`)
|
|
215
|
+
})
|
|
216
|
+
if (body.length !== desc.size) throw new RegistryError(`blob ${short}: the connection ended after ${body.length} of ${desc.size} bytes`)
|
|
217
|
+
const d = await sha256(body)
|
|
218
|
+
if (d !== desc.digest) throw new RegistryError(`blob ${desc.digest}: the bytes received hash to ${d}; refused`)
|
|
219
|
+
return body
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// W71: the OCI and Docker distribution pieces the registry proxy (web/registry/upstream.ts,
|
|
2
|
+
// pull.ts) and the page's own pull (pull.js) share. Plain JS that imports only ref.js.
|
|
3
|
+
|
|
4
|
+
import { isDigest, platformMatches, platformString } from './ref.js'
|
|
5
|
+
|
|
6
|
+
export const MT = {
|
|
7
|
+
ociIndex: 'application/vnd.oci.image.index.v1+json',
|
|
8
|
+
ociManifest: 'application/vnd.oci.image.manifest.v1+json',
|
|
9
|
+
dockerList: 'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
10
|
+
dockerManifest: 'application/vnd.docker.distribution.manifest.v2+json',
|
|
11
|
+
}
|
|
12
|
+
export const ACCEPT_MANIFEST = [MT.ociIndex, MT.ociManifest, MT.dockerList, MT.dockerManifest].join(', ')
|
|
13
|
+
export const isIndexType = mt => mt === MT.ociIndex || mt === MT.dockerList
|
|
14
|
+
export const isManifestType = mt => mt === MT.ociManifest || mt === MT.dockerManifest
|
|
15
|
+
|
|
16
|
+
const TAR = ['application/vnd.oci.image.layer.v1.tar', 'application/vnd.docker.image.rootfs.diff.tar']
|
|
17
|
+
const GZIP = ['application/vnd.oci.image.layer.v1.tar+gzip', 'application/vnd.docker.image.rootfs.diff.tar.gzip']
|
|
18
|
+
const ZSTD = ['application/vnd.oci.image.layer.v1.tar+zstd']
|
|
19
|
+
|
|
20
|
+
/** Layer media types a converter takes. zstd: the proxy's converter reads it, the page's (wmdpack.wasm) does not. */
|
|
21
|
+
export function isLayerType(mt, { zstd = true } = {}) {
|
|
22
|
+
return TAR.includes(mt) || GZIP.includes(mt) || (zstd && ZSTD.includes(mt))
|
|
23
|
+
}
|
|
24
|
+
export const isZstdLayer = mt => ZSTD.includes(mt)
|
|
25
|
+
|
|
26
|
+
export const MAX_MANIFEST_BYTES = 4 << 20
|
|
27
|
+
export const MAX_LAYERS = 256
|
|
28
|
+
|
|
29
|
+
// Exact names only (W35): a suffix match on .docker.io / .docker.com would let a token realm
|
|
30
|
+
// or a redirect send a pull to any Docker subdomain.
|
|
31
|
+
export const HUB_HOSTS = ['registry-1.docker.io', 'auth.docker.io']
|
|
32
|
+
// Blob CDN hosts. Observed 2026-10-09 with curl against the Hub API: blob GETs for alpine,
|
|
33
|
+
// python, node, bash, ubuntu, debian and busybox answer 307 to production.cloudfront.docker.com;
|
|
34
|
+
// production.cloudflare.docker.com is the older Hub blob CDN. These are allowed only for a blob
|
|
35
|
+
// GET reached by a redirect from an allowed registry host, never as a realm or a first hop.
|
|
36
|
+
// No R2 host was seen, so none is listed; add an exact name here if the Hub starts using one.
|
|
37
|
+
export const BLOB_CDN_HOSTS = ['production.cloudfront.docker.com', 'production.cloudflare.docker.com']
|
|
38
|
+
|
|
39
|
+
/** Whether a pull may connect to this URL. `local`: extra host:port names (plain http too); `blobCdn`: a blob GET redirected from an allowed host. */
|
|
40
|
+
export function hostAllowed(u, local = [], blobCdn = false) {
|
|
41
|
+
if (local.includes(u.host)) return u.protocol === 'http:' || u.protocol === 'https:'
|
|
42
|
+
if (u.protocol !== 'https:') return false
|
|
43
|
+
if (u.port) return false
|
|
44
|
+
return HUB_HOSTS.includes(u.hostname) || (blobCdn && BLOB_CDN_HOSTS.includes(u.hostname))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `Bearer realm="...",service="...",scope="..."` -> {scheme, params}. */
|
|
48
|
+
export function parseChallenge(h) {
|
|
49
|
+
const m = (h || '').match(/^\s*(\w+)\s+(.*)$/)
|
|
50
|
+
const params = {}
|
|
51
|
+
if (!m) return { scheme: '', params }
|
|
52
|
+
for (const x of m[2].matchAll(/([a-zA-Z_]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^,\s]*))\s*,?/g)) {
|
|
53
|
+
params[x[1].toLowerCase()] = x[2] !== undefined ? x[2].replace(/\\(.)/g, '$1') : x[3]
|
|
54
|
+
}
|
|
55
|
+
return { scheme: m[1].toLowerCase(), params }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const isAttestation = m =>
|
|
59
|
+
(m.platform?.os === 'unknown' && m.platform?.architecture === 'unknown') || m.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest'
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The index entry for the first of `wants` (parsed platforms, in order of preference) the index
|
|
63
|
+
* has, as {desc, platform}; a nested index without a platform when none matches; else null
|
|
64
|
+
* and the platforms it has.
|
|
65
|
+
*/
|
|
66
|
+
export function pickPlatform(index, wants) {
|
|
67
|
+
const ms = Array.isArray(index?.manifests) ? index.manifests : []
|
|
68
|
+
const usable = ms.filter(m => m && isDigest(m.digest) && !isAttestation(m))
|
|
69
|
+
for (const want of wants) {
|
|
70
|
+
const hit = usable.find(m => m.platform && platformMatches(want, m.platform))
|
|
71
|
+
if (hit) return { desc: hit, platform: want }
|
|
72
|
+
}
|
|
73
|
+
const nested = usable.find(m => isIndexType(m.mediaType || '') && !m.platform)
|
|
74
|
+
if (nested) return { desc: nested, platform: null }
|
|
75
|
+
return { desc: null, platform: null, have: [...new Set(usable.filter(m => m.platform).map(m => platformString(m.platform)))] }
|
|
76
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// W71: the Worker that turns image layers into packs with wmdpack.wasm (wmd's own converter,
|
|
2
|
+
// union.PackLayer, built for wasip1: `make wmdpack-wasm`). converter.js drives it.
|
|
3
|
+
//
|
|
4
|
+
// Messages in: {t: 'init', module: WebAssembly.Module, maxFileBytes}
|
|
5
|
+
// {t: 'add', id, sha, bytes} a pack the page already has (a cached lower layer)
|
|
6
|
+
// {t: 'convert', id, layer: ArrayBuffer, below: [sha...]} below: the lower layers' packs, top first
|
|
7
|
+
// Messages out: {t: 'done', id, sha, size, bytes, ms, fileBytes, wasmBytes} bytes: the pack (shared when the
|
|
8
|
+
// page is cross-origin isolated, so the Worker and the page hold one copy); wasmBytes: the
|
|
9
|
+
// converter's wasm memory at its end
|
|
10
|
+
// {t: 'error', id, message}
|
|
11
|
+
//
|
|
12
|
+
// The Worker keeps every pack it made or was given, by sha256: a later layer's BELOW reads them in place.
|
|
13
|
+
|
|
14
|
+
import { MemFs, runWasi } from './wasi-mem.js'
|
|
15
|
+
|
|
16
|
+
let module = null
|
|
17
|
+
let maxFileBytes = 4 * 2 ** 30
|
|
18
|
+
const packs = new Map() // 'sha256:<hex>' -> Uint8Array
|
|
19
|
+
|
|
20
|
+
const hex = buf => [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
|
|
21
|
+
const shared = typeof SharedArrayBuffer !== 'undefined' && globalThis.crossOriginIsolated === true
|
|
22
|
+
|
|
23
|
+
function convert({ id, layer, below }) {
|
|
24
|
+
const fs = new MemFs({ maxFileBytes })
|
|
25
|
+
const args = ['wmdpack', '/out.pack']
|
|
26
|
+
below.forEach((sha, i) => {
|
|
27
|
+
const b = packs.get(sha)
|
|
28
|
+
if (!b) throw new Error(`the pack ${sha.slice(0, 19)} below this layer is not in the converter`)
|
|
29
|
+
fs.addFile(`below/${i}.pack`, b)
|
|
30
|
+
args.push(`/below/${i}.pack`)
|
|
31
|
+
})
|
|
32
|
+
const t0 = performance.now()
|
|
33
|
+
const r = runWasi(module, { args, stdin: new Uint8Array(layer), fs })
|
|
34
|
+
const ms = Math.round(performance.now() - t0)
|
|
35
|
+
if (r.code !== 0) throw new Error(`converting the layer: ${r.stderr.trim() || `exit ${r.code}`}`)
|
|
36
|
+
const out = fs.take('out.pack')
|
|
37
|
+
if (!out) throw new Error('the converter wrote no pack')
|
|
38
|
+
return { out, ms, fileBytes: Number(r.stdout.trim()) || 0, wasmBytes: r.memBytes }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
self.onmessage = async ({ data: m }) => {
|
|
42
|
+
try {
|
|
43
|
+
if (m.t === 'init') {
|
|
44
|
+
module = m.module
|
|
45
|
+
if (m.maxFileBytes) maxFileBytes = m.maxFileBytes
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (m.t === 'add') {
|
|
49
|
+
packs.set(m.sha, m.bytes)
|
|
50
|
+
self.postMessage({ t: 'added', id: m.id })
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (m.t === 'convert') {
|
|
54
|
+
const { out, ms, fileBytes, wasmBytes } = convert(m)
|
|
55
|
+
// WebCrypto refuses views of shared memory: hash before the shared copy
|
|
56
|
+
const sha = `sha256:${hex(await crypto.subtle.digest('SHA-256', out))}`
|
|
57
|
+
let bytes = out
|
|
58
|
+
if (shared) {
|
|
59
|
+
bytes = new Uint8Array(new SharedArrayBuffer(out.length))
|
|
60
|
+
bytes.set(out)
|
|
61
|
+
}
|
|
62
|
+
packs.set(sha, bytes)
|
|
63
|
+
self.postMessage({ t: 'done', id: m.id, sha, size: bytes.length, bytes: shared ? bytes : bytes.slice(), ms, fileBytes, wasmBytes })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`unknown message ${m.t}`)
|
|
67
|
+
} catch (e) {
|
|
68
|
+
self.postMessage({ t: 'error', id: m.id, message: e?.message || String(e) })
|
|
69
|
+
}
|
|
70
|
+
}
|