@threadedstack/wmd-web 0.1.17 → 0.1.18
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 +44 -0
- package/RELEASE.json +39 -54
- package/guest/arm64/Image +2 -2
- package/guest/arm64/Image-4k +1 -1
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +4 -4
- package/relay/hosted.ts +10 -8
- package/relay/main.ts +2 -2
- package/relay/relay.ts +1 -0
- package/relay/token.ts +34 -5
- package/relay/wisp.ts +37 -5
- package/src/boot.js +25 -0
- package/src/build/builder.js +791 -0
- package/src/build/context.js +349 -0
- package/src/build/dockerfile.js +1055 -0
- package/src/build/dockerignore.js +270 -0
- package/src/build/fast.js +1187 -0
- package/src/build/inspect.js +78 -0
- package/src/build/pin.js +6 -0
- package/src/build/tar.js +304 -0
- package/src/host/ctl.js +17 -0
- package/src/host/disk-map.js +330 -0
- package/src/host/disk-worker.js +125 -0
- package/src/host/disk.js +219 -0
- package/src/host/wmd-imports.js +3 -3
- package/src/image-files.js +2 -1
- package/src/registry/hub.js +113 -44
- package/src/registry/load-tar.js +473 -0
- package/src/registry/pull.js +5 -3
- package/src/worker.js +2 -0
- package/wmdpack.wasm +0 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// An image tar -> packs, in the page: the other half of pull.js. It reads what `buildctl build --output
|
|
2
|
+
// type=oci` or `type=docker` writes (an OCI image layout: oci-layout, index.json, blobs/sha256/..., and for
|
|
3
|
+
// type=docker also manifest.json) or a `docker save` tar (manifest.json, <hex>.json, layer tars), and returns
|
|
4
|
+
// what pullImage returns: { index, packs } with an IMAGE_SCHEMA index (layers top first) and the packs in that
|
|
5
|
+
// order, converted with wmdpack.wasm exactly as pull.js converts a downloaded layer. Also `name`: the
|
|
6
|
+
// io.containerd.image.name annotation of the index, or null.
|
|
7
|
+
//
|
|
8
|
+
// const { index, packs, name } = await loadImageTar(bytes, { converter: wmdpackWasmUrl, platform: 'linux/arm64' })
|
|
9
|
+
//
|
|
10
|
+
// The tar is read in place: a Uint8Array is indexed (entry offsets) and each blob is a view of it (the one
|
|
11
|
+
// copy is the layer handed to the converter Worker); a Blob is read one entry at a time, so the whole tar is
|
|
12
|
+
// never in memory at once.
|
|
13
|
+
|
|
14
|
+
import { createConverter } from './converter.js'
|
|
15
|
+
import { RegistryError, sha256 } from './hub.js'
|
|
16
|
+
import { CONVERTER } from './pull.js'
|
|
17
|
+
import { isAttestation, isIndexType, isLayerType, isManifestType, isZstdLayer, MAX_LAYERS, pickPlatform } from './oci.js'
|
|
18
|
+
import { IMAGE_SCHEMA, isDigest, parsePlatform, platformMatches, platformString } from './ref.js'
|
|
19
|
+
|
|
20
|
+
const MAX_JSON = 4 << 20
|
|
21
|
+
const NAME_ANNOTATION = 'io.containerd.image.name'
|
|
22
|
+
const dec = new TextDecoder()
|
|
23
|
+
const unshared = b => (typeof SharedArrayBuffer !== 'undefined' && b.buffer instanceof SharedArrayBuffer ? b.slice() : b)
|
|
24
|
+
const fail = msg => {
|
|
25
|
+
throw new RegistryError(`image tar: ${msg}`, 400)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
const K256 = Uint32Array.from(
|
|
30
|
+
'428a2f98 71374491 b5c0fbcf e9b5dba5 3956c25b 59f111f1 923f82a4 ab1c5ed5 d807aa98 12835b01 243185be 550c7dc3 72be5d74 80deb1fe 9bdc06a7 c19bf174 e49b69c1 efbe4786 0fc19dc6 240ca1cc 2de92c6f 4a7484aa 5cb0a9dc 76f988da 983e5152 a831c66d b00327c8 bf597fc7 c6e00bf3 d5a79147 06ca6351 14292967 27b70a85 2e1b2138 4d2c6dfc 53380d13 650a7354 766a0abb 81c2c92e 92722c85 a2bfe8a1 a81a664b c24b8b70 c76c51a3 d192e819 d6990624 f40e3585 106aa070 19a4c116 1e376c08 2748774c 34b0bcb5 391c0cb3 4ed8aa4a 5b9cca4f 682e6ff3 748f82ee 78a5636f 84c87814 8cc70208 90befffa a4506ceb bef9a3f7 c67178f2'
|
|
31
|
+
.split(' ')
|
|
32
|
+
.map(x => parseInt(x, 16)),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
/** An incremental SHA-256 (WebCrypto has none): update(chunk) as the bytes arrive, digest() -> 'sha256:<hex>'. */
|
|
36
|
+
class Sha256 {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.h = Uint32Array.of(0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19)
|
|
39
|
+
this.buf = new Uint8Array(64)
|
|
40
|
+
this.n = 0 // bytes in buf
|
|
41
|
+
this.len = 0
|
|
42
|
+
this.w = new Uint32Array(64)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
block(b, o) {
|
|
46
|
+
const { h, w } = this
|
|
47
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n))
|
|
48
|
+
for (let i = 0; i < 16; i++) w[i] = ((b[o + i * 4] << 24) | (b[o + i * 4 + 1] << 16) | (b[o + i * 4 + 2] << 8) | b[o + i * 4 + 3]) | 0
|
|
49
|
+
for (let i = 16; i < 64; i++) {
|
|
50
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
|
|
51
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
|
|
52
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0
|
|
53
|
+
}
|
|
54
|
+
let [a, b2, c, d, e, f, g, hh] = h
|
|
55
|
+
for (let i = 0; i < 64; i++) {
|
|
56
|
+
const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K256[i] + w[i]) | 0
|
|
57
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b2) ^ (a & c) ^ (b2 & c))) | 0
|
|
58
|
+
hh = g
|
|
59
|
+
g = f
|
|
60
|
+
f = e
|
|
61
|
+
e = (d + t1) | 0
|
|
62
|
+
d = c
|
|
63
|
+
c = b2
|
|
64
|
+
b2 = a
|
|
65
|
+
a = (t1 + t2) | 0
|
|
66
|
+
}
|
|
67
|
+
h[0] += a
|
|
68
|
+
h[1] += b2
|
|
69
|
+
h[2] += c
|
|
70
|
+
h[3] += d
|
|
71
|
+
h[4] += e
|
|
72
|
+
h[5] += f
|
|
73
|
+
h[6] += g
|
|
74
|
+
h[7] += hh
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
update(u8) {
|
|
78
|
+
this.len += u8.length
|
|
79
|
+
let i = 0
|
|
80
|
+
if (this.n) {
|
|
81
|
+
const take = Math.min(64 - this.n, u8.length)
|
|
82
|
+
this.buf.set(u8.subarray(0, take), this.n)
|
|
83
|
+
this.n += take
|
|
84
|
+
i = take
|
|
85
|
+
if (this.n < 64) return
|
|
86
|
+
this.block(this.buf, 0)
|
|
87
|
+
this.n = 0
|
|
88
|
+
}
|
|
89
|
+
for (; i + 64 <= u8.length; i += 64) this.block(u8, i)
|
|
90
|
+
this.buf.set(u8.subarray(i), 0)
|
|
91
|
+
this.n = u8.length - i
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
digest() {
|
|
95
|
+
const bits = this.len * 8
|
|
96
|
+
const pad = new Uint8Array((this.n < 56 ? 64 : 128) - this.n)
|
|
97
|
+
pad[0] = 0x80
|
|
98
|
+
const dv = new DataView(pad.buffer)
|
|
99
|
+
dv.setUint32(pad.length - 8, Math.floor(bits / 2 ** 32))
|
|
100
|
+
dv.setUint32(pad.length - 4, bits >>> 0)
|
|
101
|
+
const keep = this.len
|
|
102
|
+
this.update(pad)
|
|
103
|
+
this.len = keep
|
|
104
|
+
return `sha256:${[...this.h].map(x => (x >>> 0).toString(16).padStart(8, '0')).join('')}`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** sha256 of the gunzipped bytes, streamed (the decompressed layer is never held whole). */
|
|
109
|
+
async function gunzipDigest(bytes, what) {
|
|
110
|
+
const sha = new Sha256()
|
|
111
|
+
try {
|
|
112
|
+
const rd = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')).getReader()
|
|
113
|
+
for (;;) {
|
|
114
|
+
const { done, value } = await rd.read()
|
|
115
|
+
if (done) break
|
|
116
|
+
sha.update(value)
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {
|
|
119
|
+
fail(`${what} is not valid gzip (${e.message})`)
|
|
120
|
+
}
|
|
121
|
+
return sha.digest()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A reader over the tar's bytes: read(off, len) -> Uint8Array (a view for bytes, a fresh copy for a Blob). */
|
|
125
|
+
function source(input) {
|
|
126
|
+
if (input instanceof Uint8Array) return { size: input.length, read: async (off, len) => input.subarray(off, off + len) }
|
|
127
|
+
if (input instanceof ArrayBuffer) return source(new Uint8Array(input))
|
|
128
|
+
if (typeof Blob !== 'undefined' && input instanceof Blob) {
|
|
129
|
+
return { size: input.size, read: async (off, len) => new Uint8Array(await input.slice(off, off + len).arrayBuffer()) }
|
|
130
|
+
}
|
|
131
|
+
throw new TypeError('loadImageTar: the tar as a Uint8Array, ArrayBuffer or Blob')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const field = (h, a, l) => dec.decode(h.subarray(a, a + l)).replace(/\0[\s\S]*$/, '')
|
|
135
|
+
function tarSize(h) {
|
|
136
|
+
if (h[124] & 0x80) {
|
|
137
|
+
// base-256 (GNU)
|
|
138
|
+
let n = 0
|
|
139
|
+
for (let i = 125; i < 136; i++) n = n * 256 + h[i]
|
|
140
|
+
return n
|
|
141
|
+
}
|
|
142
|
+
return parseInt(field(h, 124, 12).trim() || '0', 8)
|
|
143
|
+
}
|
|
144
|
+
const paxFields = text => {
|
|
145
|
+
const o = {}
|
|
146
|
+
for (const line of text.split('\n')) {
|
|
147
|
+
const sp = line.indexOf(' ')
|
|
148
|
+
const eq = line.indexOf('=', sp)
|
|
149
|
+
if (sp > 0 && eq > 0) o[line.slice(sp + 1, eq)] = line.slice(eq + 1)
|
|
150
|
+
}
|
|
151
|
+
return o
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const isUstar = h => field(h, 257, 6) === 'ustar' && h[263] === 0x30 && h[264] === 0x30
|
|
155
|
+
function checksumOk(h) {
|
|
156
|
+
const want = parseInt(field(h, 148, 8).trim() || 'x', 8)
|
|
157
|
+
if (!Number.isInteger(want)) return false
|
|
158
|
+
let u = 0
|
|
159
|
+
let sg = 0
|
|
160
|
+
for (let i = 0; i < 512; i++) {
|
|
161
|
+
const c = i >= 148 && i < 156 ? 0x20 : h[i]
|
|
162
|
+
u += c
|
|
163
|
+
sg += c > 127 ? c - 256 : c
|
|
164
|
+
}
|
|
165
|
+
return want === u || want === sg
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The files of the tar: Map name -> {off, size, type, link}. type '0' is a regular file, '1' a hard link and
|
|
170
|
+
* '2' a symlink (link is its target). Reads the 512-byte headers only (and the metadata records).
|
|
171
|
+
*/
|
|
172
|
+
async function indexTar(src) {
|
|
173
|
+
const files = new Map()
|
|
174
|
+
let off = 0
|
|
175
|
+
let longName = null
|
|
176
|
+
let longLink = null
|
|
177
|
+
let pax = null
|
|
178
|
+
while (off + 512 <= src.size) {
|
|
179
|
+
const h = await src.read(off, 512)
|
|
180
|
+
if (h.length < 512 || h.every(x => x === 0)) break
|
|
181
|
+
if (!checksumOk(h)) fail(`bad tar header checksum at offset ${off}`)
|
|
182
|
+
let name = field(h, 0, 100)
|
|
183
|
+
const prefix = field(h, 345, 155)
|
|
184
|
+
if (prefix && isUstar(h)) name = `${prefix}/${name}`
|
|
185
|
+
let size = tarSize(h)
|
|
186
|
+
const type = String.fromCharCode(h[156] || 48)
|
|
187
|
+
const meta = 'LKxgDV'.includes(type)
|
|
188
|
+
if (type === 'x' || type === 'L' || type === 'K') {
|
|
189
|
+
// a record's own size is the header's
|
|
190
|
+
} else if (pax?.size !== undefined && !meta) {
|
|
191
|
+
size = Number(pax.size)
|
|
192
|
+
}
|
|
193
|
+
if (!Number.isSafeInteger(size) || size < 0) fail(`bad entry size for ${name}`)
|
|
194
|
+
const body = off + 512
|
|
195
|
+
if (body + size > src.size) fail(`the tar is cut short inside ${name}`)
|
|
196
|
+
off = body + Math.ceil(size / 512) * 512
|
|
197
|
+
if (meta) {
|
|
198
|
+
if (type === 'g' || type === 'D' || type === 'V') continue
|
|
199
|
+
if (size > 1 << 20) fail('an oversized name record')
|
|
200
|
+
const text = dec.decode(await src.read(body, size))
|
|
201
|
+
if (type === 'L') longName = text.replace(/\0[\s\S]*$/, '')
|
|
202
|
+
else if (type === 'K') longLink = text.replace(/\0[\s\S]*$/, '')
|
|
203
|
+
else pax = paxFields(text)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
let link = field(h, 157, 100)
|
|
207
|
+
if (pax?.path) name = pax.path
|
|
208
|
+
if (longName) name = longName
|
|
209
|
+
if (pax?.linkpath) link = pax.linkpath
|
|
210
|
+
if (longLink) link = longLink
|
|
211
|
+
longName = null
|
|
212
|
+
longLink = null
|
|
213
|
+
pax = null
|
|
214
|
+
name = name.replace(/^(\.\/)+/, '')
|
|
215
|
+
if (type === '0') files.set(name, { off: body, size, type })
|
|
216
|
+
else if (type === '1' || type === '2') files.set(name, { off: body, size: 0, type, link })
|
|
217
|
+
}
|
|
218
|
+
return files
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const normPath = p => {
|
|
222
|
+
const out = []
|
|
223
|
+
for (const seg of p.split('/')) {
|
|
224
|
+
if (seg === '' || seg === '.') continue
|
|
225
|
+
if (seg === '..') out.pop()
|
|
226
|
+
else out.push(seg)
|
|
227
|
+
}
|
|
228
|
+
return out.join('/')
|
|
229
|
+
}
|
|
230
|
+
/** The regular file a name leads to, through symlinks and hard links (at most 16 hops), or undefined. */
|
|
231
|
+
function resolveEntry(files, name) {
|
|
232
|
+
let path = normPath(name)
|
|
233
|
+
for (let hops = 0; hops <= 16; hops++) {
|
|
234
|
+
const e = files.get(path)
|
|
235
|
+
if (!e) return undefined
|
|
236
|
+
if (e.type === '0') return e
|
|
237
|
+
// a symlink is relative to its folder (or absolute from the tar's root); a hard link names a path from the root
|
|
238
|
+
path = e.type === '2' && !e.link.startsWith('/') ? normPath(`${path.split('/').slice(0, -1).join('/')}/${e.link}`) : normPath(e.link)
|
|
239
|
+
}
|
|
240
|
+
return fail(`${name}: too many links`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const isGzip = b => b.length > 2 && b[0] === 0x1f && b[1] === 0x8b
|
|
244
|
+
const isZstd = b => b.length >= 4 && b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {Uint8Array|ArrayBuffer|Blob} input the image tar
|
|
248
|
+
* @param {object} o
|
|
249
|
+
* @param {string|URL|Uint8Array|WebAssembly.Module|{convert: Function, add: Function}} o.converter wmdpack.wasm, or a converter from createConverter
|
|
250
|
+
* @param {string|string[]} [o.platform] default linux/arm64
|
|
251
|
+
* @returns {Promise<{index: object, packs: Uint8Array[], name: string|null}>} packs top first, as index.layers
|
|
252
|
+
*/
|
|
253
|
+
export async function loadImageTar(input, o = {}) {
|
|
254
|
+
const t0 = performance.now()
|
|
255
|
+
if (!o.converter) throw new TypeError('loadImageTar: pass converter: wmdpack.wasm (a URL, its bytes or a WebAssembly.Module)')
|
|
256
|
+
const wants = (Array.isArray(o.platform) ? o.platform : [o.platform || 'linux/arm64']).map(p => parsePlatform(p))
|
|
257
|
+
const wantsText = wants.map(platformString).join(' or ')
|
|
258
|
+
const src = source(input)
|
|
259
|
+
const magic = await src.read(0, 4)
|
|
260
|
+
if (isGzip(magic) || isZstd(magic)) fail(`the image tar is compressed (${isGzip(magic) ? 'gzip' : 'zstd'}); give the plain tar`)
|
|
261
|
+
const files = await indexTar(src)
|
|
262
|
+
const entry = path => {
|
|
263
|
+
const e = resolveEntry(files, path)
|
|
264
|
+
if (!e) fail(`it has no ${path}`)
|
|
265
|
+
return e
|
|
266
|
+
}
|
|
267
|
+
const readFile = async path => {
|
|
268
|
+
const e = entry(path)
|
|
269
|
+
return src.read(e.off, e.size)
|
|
270
|
+
}
|
|
271
|
+
const readJson = async (path, what) => {
|
|
272
|
+
const e = entry(path)
|
|
273
|
+
if (e.size > MAX_JSON) fail(`${what} is ${e.size} bytes (at most ${MAX_JSON})`)
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(dec.decode(await src.read(e.off, e.size)))
|
|
276
|
+
} catch {
|
|
277
|
+
return fail(`${what} is not JSON`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** A blob by descriptor: size and sha256 checked. */
|
|
281
|
+
const blob = async (d, what) => {
|
|
282
|
+
if (!isDigest(d?.digest)) fail(`bad ${what} descriptor ${JSON.stringify(d).slice(0, 200)}`)
|
|
283
|
+
const path = `blobs/sha256/${d.digest.slice(7)}`
|
|
284
|
+
const e = resolveEntry(files, path)
|
|
285
|
+
if (!e) fail(`it has no blob ${d.digest} (${what})`)
|
|
286
|
+
if (Number.isSafeInteger(d.size) && d.size !== e.size) fail(`${what} ${d.digest.slice(0, 19)}: ${e.size} bytes, the descriptor says ${d.size}`)
|
|
287
|
+
const b = await src.read(e.off, e.size)
|
|
288
|
+
const got = await sha256(unshared(b))
|
|
289
|
+
if (got !== d.digest) fail(`${what} ${d.digest.slice(0, 19)} hashes to ${got.slice(0, 19)}`)
|
|
290
|
+
return b
|
|
291
|
+
}
|
|
292
|
+
const blobJson = async (d, what) => {
|
|
293
|
+
if (d.size > MAX_JSON) fail(`${what} is ${d.size} bytes (at most ${MAX_JSON})`)
|
|
294
|
+
const b = await blob(d, what)
|
|
295
|
+
try {
|
|
296
|
+
return JSON.parse(dec.decode(b))
|
|
297
|
+
} catch {
|
|
298
|
+
return fail(`${what} is not JSON`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let name = null
|
|
303
|
+
let indexDigest = null
|
|
304
|
+
let manifestDigest = null
|
|
305
|
+
let cfg
|
|
306
|
+
let layers // [{digest, mediaType, size, get: () => Promise<Uint8Array>}] bottom first
|
|
307
|
+
let have = []
|
|
308
|
+
|
|
309
|
+
if (resolveEntry(files, 'index.json')) {
|
|
310
|
+
// an OCI layout (also what type=docker writes, beside manifest.json)
|
|
311
|
+
if (resolveEntry(files, 'oci-layout')) {
|
|
312
|
+
const l = await readJson('oci-layout', 'oci-layout')
|
|
313
|
+
if (l.imageLayoutVersion && !/^1\./.test(l.imageLayoutVersion)) fail(`unsupported image layout ${l.imageLayoutVersion}`)
|
|
314
|
+
}
|
|
315
|
+
const top = await readJson('index.json', 'index.json')
|
|
316
|
+
if (!Array.isArray(top.manifests) || top.manifests.length === 0) fail('index.json lists no manifests')
|
|
317
|
+
for (const m of top.manifests) name ??= m?.annotations?.[NAME_ANNOTATION] || null
|
|
318
|
+
let doc = top
|
|
319
|
+
let want = null
|
|
320
|
+
let man = null
|
|
321
|
+
for (let depth = 0; ; depth++) {
|
|
322
|
+
if (depth > 3) fail('indexes nested too deep')
|
|
323
|
+
const pick = pickPlatform(doc, want ? [want] : wants)
|
|
324
|
+
have = [...new Set([...have, ...pick.have || []])]
|
|
325
|
+
let desc = pick.desc
|
|
326
|
+
if (!desc) {
|
|
327
|
+
// BuildKit's index.json names its image manifest without a platform: take it, the config is checked below
|
|
328
|
+
desc = doc.manifests.find(m => m && isDigest(m.digest) && !m.platform && !isAttestation(m) && !isIndexType(m.mediaType || ''))
|
|
329
|
+
if (!desc) break
|
|
330
|
+
} else if (pick.platform) want = pick.platform
|
|
331
|
+
const d = desc
|
|
332
|
+
const sub = await blobJson(d, 'manifest')
|
|
333
|
+
if (isIndexType(d.mediaType || '') || (Array.isArray(sub.manifests) && !sub.layers)) {
|
|
334
|
+
indexDigest ??= d.digest
|
|
335
|
+
doc = sub
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
man = { doc: sub, digest: d.digest, mediaType: d.mediaType || sub.mediaType || '' }
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
if (!man) fail(`no ${wantsText} image in it (it has: ${have.length ? have.join(', ') : 'no platforms'})`)
|
|
342
|
+
if (man.doc.schemaVersion === 1 || !(isManifestType(man.mediaType) || Array.isArray(man.doc.layers))) fail(`unsupported manifest type ${JSON.stringify(man.mediaType)}`)
|
|
343
|
+
manifestDigest = man.digest
|
|
344
|
+
if (!man.doc.config || !isDigest(man.doc.config.digest)) fail('the manifest has no config')
|
|
345
|
+
cfg = await blobJson(man.doc.config, 'config')
|
|
346
|
+
const ls = man.doc.layers
|
|
347
|
+
if (!Array.isArray(ls) || ls.length === 0) fail('the manifest has no layers')
|
|
348
|
+
if (ls.length > MAX_LAYERS) fail(`${ls.length} layers (at most ${MAX_LAYERS})`)
|
|
349
|
+
for (const l of ls) {
|
|
350
|
+
if (!isDigest(l?.digest) || !Number.isSafeInteger(l.size) || l.size < 0) fail(`bad layer descriptor ${JSON.stringify(l).slice(0, 200)}`)
|
|
351
|
+
if (isZstdLayer(l.mediaType)) fail(`layer ${l.digest.slice(0, 19)} is zstd; the page's converter takes tar and gzip layers only`)
|
|
352
|
+
if (!isLayerType(l.mediaType || '', { zstd: false })) fail(`layer ${l.digest.slice(0, 19)} has media type ${l.mediaType} (foreign or not a filesystem tar)`)
|
|
353
|
+
}
|
|
354
|
+
layers = ls.map(l => ({ digest: l.digest, size: l.size, get: () => blob(l, 'layer') }))
|
|
355
|
+
} else if (resolveEntry(files, 'manifest.json')) {
|
|
356
|
+
// docker save
|
|
357
|
+
const list = await readJson('manifest.json', 'manifest.json')
|
|
358
|
+
if (!Array.isArray(list) || list.length === 0) fail('manifest.json lists no images')
|
|
359
|
+
const picks = []
|
|
360
|
+
for (const m of list) {
|
|
361
|
+
const cb = await readFile(String(m?.Config ?? ''))
|
|
362
|
+
let c
|
|
363
|
+
try {
|
|
364
|
+
c = JSON.parse(dec.decode(cb))
|
|
365
|
+
} catch {
|
|
366
|
+
fail(`${m.Config} is not JSON`)
|
|
367
|
+
}
|
|
368
|
+
const p = c.os && c.architecture ? { os: c.os, architecture: c.architecture, variant: c.variant || '' } : null
|
|
369
|
+
picks.push({ m, c, cb, p })
|
|
370
|
+
if (p) have.push(platformString(p))
|
|
371
|
+
}
|
|
372
|
+
const hit = picks.find(x => x.p && wants.some(w => platformMatches(w, x.p)))
|
|
373
|
+
if (!hit) fail(`no ${wantsText} image in it (it has: ${have.length ? [...new Set(have)].join(', ') : 'no platforms'})`)
|
|
374
|
+
const { m, c, cb } = hit
|
|
375
|
+
const cm = /^(?:blobs\/sha256\/)?([0-9a-f]{64})(?:\.json)?$/.exec(m.Config)
|
|
376
|
+
if (cm && (await sha256(unshared(cb))) !== `sha256:${cm[1]}`) fail(`${m.Config} does not hash to its name`)
|
|
377
|
+
cfg = c
|
|
378
|
+
name = Array.isArray(m.RepoTags) && m.RepoTags.length ? m.RepoTags[0] : null
|
|
379
|
+
const paths = m.Layers
|
|
380
|
+
if (!Array.isArray(paths) || paths.length === 0) fail('the image has no layers')
|
|
381
|
+
if (paths.length > MAX_LAYERS) fail(`${paths.length} layers (at most ${MAX_LAYERS})`)
|
|
382
|
+
const diffIds = Array.isArray(c.rootfs?.diff_ids) ? c.rootfs.diff_ids : []
|
|
383
|
+
layers = paths.map((p, i) => ({
|
|
384
|
+
digest: null,
|
|
385
|
+
size: resolveEntry(files, p)?.size ?? fail(`it has no layer ${p}`),
|
|
386
|
+
get: async () => {
|
|
387
|
+
const b = await readFile(p)
|
|
388
|
+
if (isZstd(b)) fail(`layer ${p} is zstd; the page's converter takes tar and gzip layers only`)
|
|
389
|
+
const got = await sha256(unshared(b))
|
|
390
|
+
// the config's diff_id is the digest of the uncompressed tar (a gzip layer is gunzipped, streamed, to check it)
|
|
391
|
+
const diff = isGzip(b) ? await gunzipDigest(b, `layer ${p}`) : got
|
|
392
|
+
if (diffIds[i] && diffIds[i] !== diff) fail(`layer ${p} hashes to ${diff.slice(0, 19)}, the config says ${diffIds[i].slice(0, 19)}`)
|
|
393
|
+
return { b, digest: diffIds[i] || diff }
|
|
394
|
+
},
|
|
395
|
+
}))
|
|
396
|
+
} else {
|
|
397
|
+
fail('it is neither an OCI image layout (index.json) nor a docker save tar (manifest.json)')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// the platform is checked against the config too: an index entry can lie
|
|
401
|
+
const cfgPlat = cfg.os && cfg.architecture ? { os: cfg.os, architecture: cfg.architecture, variant: cfg.variant || '' } : null
|
|
402
|
+
const want = cfgPlat ? wants.find(w => platformMatches(w, cfgPlat)) : wants[0]
|
|
403
|
+
if (!want || (cfgPlat && !platformMatches(want, cfgPlat))) {
|
|
404
|
+
const had = [...new Set([...have, ...(cfgPlat ? [platformString(cfgPlat)] : [])])]
|
|
405
|
+
fail(`no ${wantsText} image in it (it has: ${had.length ? had.join(', ') : 'no platforms'})`)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// layers, bottom first, each converted over the packs below it
|
|
409
|
+
const ownConverter = !(typeof o.converter?.convert === 'function')
|
|
410
|
+
const conv = ownConverter ? await createConverter(o.converter) : o.converter
|
|
411
|
+
const out = []
|
|
412
|
+
let convertMs = 0
|
|
413
|
+
let fileBytes = 0
|
|
414
|
+
let wasmBytes = 0
|
|
415
|
+
let layerBytes = 0
|
|
416
|
+
try {
|
|
417
|
+
for (let i = 0; i < layers.length; i++) {
|
|
418
|
+
const l = layers[i]
|
|
419
|
+
const got = await l.get()
|
|
420
|
+
const b = got.b || got
|
|
421
|
+
if (isZstd(b)) fail(`layer ${l.digest ? l.digest.slice(0, 19) : i} is zstd; the page's converter takes tar and gzip layers only`)
|
|
422
|
+
layerBytes += b.length
|
|
423
|
+
const below = out.map(x => x.pack).reverse()
|
|
424
|
+
const r = await conv.convert(b, below)
|
|
425
|
+
convertMs += r.ms
|
|
426
|
+
fileBytes += r.fileBytes
|
|
427
|
+
wasmBytes = Math.max(wasmBytes, r.wasmBytes || 0)
|
|
428
|
+
out.push({ pack: r.sha, size: r.size, layer: l.digest || got.digest, bytes: r.bytes })
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
if (ownConverter) conv.close()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const c = cfg.config || {}
|
|
435
|
+
const top = out.slice().reverse()
|
|
436
|
+
const index = {
|
|
437
|
+
schema: IMAGE_SCHEMA,
|
|
438
|
+
ref: name || '',
|
|
439
|
+
platform: platformString(want),
|
|
440
|
+
index: indexDigest,
|
|
441
|
+
manifest: manifestDigest,
|
|
442
|
+
os: cfg.os || null,
|
|
443
|
+
architecture: cfg.architecture || null,
|
|
444
|
+
config: {
|
|
445
|
+
Env: c.Env || null,
|
|
446
|
+
Cmd: c.Cmd || null,
|
|
447
|
+
Entrypoint: c.Entrypoint || null,
|
|
448
|
+
WorkingDir: c.WorkingDir || '',
|
|
449
|
+
User: c.User || '',
|
|
450
|
+
ExposedPorts: c.ExposedPorts || null,
|
|
451
|
+
Labels: c.Labels || null,
|
|
452
|
+
Volumes: c.Volumes || null,
|
|
453
|
+
StopSignal: c.StopSignal || '',
|
|
454
|
+
Healthcheck: c.Healthcheck || null,
|
|
455
|
+
},
|
|
456
|
+
layers: top.map(x => ({ pack: x.pack, size: x.size, layer: x.layer })),
|
|
457
|
+
converter: CONVERTER,
|
|
458
|
+
pulled: new Date().toISOString(),
|
|
459
|
+
stats: {
|
|
460
|
+
totalMs: Math.round(performance.now() - t0),
|
|
461
|
+
layerBytes,
|
|
462
|
+
downloadBytes: 0,
|
|
463
|
+
downloadMs: 0,
|
|
464
|
+
convertMs,
|
|
465
|
+
fileBytes,
|
|
466
|
+
wasmBytes,
|
|
467
|
+
packBytes: out.reduce((a, x) => a + x.size, 0),
|
|
468
|
+
cachedLayers: 0,
|
|
469
|
+
tarFiles: files.size,
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
return { index, packs: top.map(x => x.bytes), name, rawConfig: cfg }
|
|
473
|
+
}
|
package/src/registry/pull.js
CHANGED
|
@@ -66,7 +66,8 @@ async function fromStore(store, key) {
|
|
|
66
66
|
* @param {{get: (key: string) => any, put: (key: string, bytes: Uint8Array) => any}} [o.store]
|
|
67
67
|
* @param {number} [o.concurrency] downloads at once (default 3)
|
|
68
68
|
* @param {string[]} [o.local] test registry host:port names allowed besides Docker Hub
|
|
69
|
-
* @
|
|
69
|
+
* @param {string} [o.mirror] 'http(s)://host[:port]': a Docker Hub mirror tried first for docker.io references (its host is allowed because it is named here)
|
|
70
|
+
* @returns {Promise<{index: object, packs: Uint8Array[], rawConfig: object}>} packs top first, as index.layers
|
|
70
71
|
*/
|
|
71
72
|
export async function pullImage(refStr, o = {}) {
|
|
72
73
|
const t0 = performance.now()
|
|
@@ -74,7 +75,7 @@ export async function pullImage(refStr, o = {}) {
|
|
|
74
75
|
const wants = (Array.isArray(o.platform) ? o.platform : o.platform ? [o.platform] : DEFAULT_PLATFORMS).map(p => parsePlatform(p))
|
|
75
76
|
const ev = o.onProgress || (() => {})
|
|
76
77
|
const sig = o.signal
|
|
77
|
-
const hub = new HubRepo(ref, { fetch: o.fetch, local: o.local })
|
|
78
|
+
const hub = new HubRepo(ref, { fetch: o.fetch, local: o.local, mirror: o.mirror })
|
|
78
79
|
if (!o.converter) throw new TypeError('pullImage: pass converter: wmdpack.wasm (a URL, its bytes or a WebAssembly.Module)')
|
|
79
80
|
|
|
80
81
|
// 1. resolve
|
|
@@ -254,5 +255,6 @@ export async function pullImage(refStr, o = {}) {
|
|
|
254
255
|
cachedLayers: out.filter(x => x.cached).length,
|
|
255
256
|
},
|
|
256
257
|
}
|
|
257
|
-
|
|
258
|
+
// rawConfig: the image config blob as parsed JSON (rootfs.diff_ids, history, config.OnBuild, config.Shell...): the build fast path reads it
|
|
259
|
+
return { index, packs: top.map(x => x.bytes), rawConfig: cfg }
|
|
258
260
|
}
|
package/src/worker.js
CHANGED
|
@@ -35,6 +35,7 @@ import { WasiExit, createWasi } from './host/wasi.js'
|
|
|
35
35
|
import { createJitDoor, poolProbe, TABLE_MIN } from './host/jit.js'
|
|
36
36
|
import { stdioWriter } from './host/stdio.js'
|
|
37
37
|
import { createWmdImports } from './host/wmd-imports.js'
|
|
38
|
+
import { createDiskImports } from './host/disk.js'
|
|
38
39
|
import { createRamImports } from './host/ram-mem.js'
|
|
39
40
|
import { createChanImports } from './host/chan.js'
|
|
40
41
|
import { createCkptImports } from './host/ckpt-point.js'
|
|
@@ -176,6 +177,7 @@ async function run(m) {
|
|
|
176
177
|
chan,
|
|
177
178
|
ckpt,
|
|
178
179
|
ram: ramImports,
|
|
180
|
+
blk: m.blk ? createDiskImports({ memory, mailbox: m.blk }) : null, // a disk (host/disk.js)
|
|
179
181
|
counters: ctl, // stub calls (chan_open, snapshot, jit_* without a door) are counted in ctl, read by the page
|
|
180
182
|
})
|
|
181
183
|
|
package/wmdpack.wasm
CHANGED
|
Binary file
|