@threadedstack/wmd-web 0.1.16 → 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.
@@ -0,0 +1,78 @@
1
+ // Reading built layers back, for the fast-path parity gate (docs/plans/web-buildkit-impl.md 5.5): the entries of a
2
+ // layer tar (path, type, mode, owner, size, sha256, link target, xattrs, mtime) and the layers of a Docker tar
3
+ // (`docker save` layout or an OCI layout, layers plain or gzip).
4
+ import { scanTarBytes } from './tar.js'
5
+
6
+ const dec = new TextDecoder()
7
+ const TYPES = { 0: 'file', 7: 'file', 1: 'hardlink', 2: 'symlink', 3: 'char', 4: 'block', 5: 'dir', 6: 'fifo' }
8
+
9
+ const hex = (buf) => [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, '0')).join('')
10
+ const sha256Hex = async (u8) => hex(await crypto.subtle.digest('SHA-256', u8))
11
+
12
+ /** gunzip when the bytes start with the gzip magic; else the bytes themselves. */
13
+ export async function maybeGunzip(u8) {
14
+ if (!(u8.length > 2 && u8[0] === 0x1f && u8[1] === 0x8b)) return u8
15
+ const s = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip'))
16
+ return new Uint8Array(await new Response(s).arrayBuffer())
17
+ }
18
+
19
+ /** './a/', '/a' and 'a' are the same path: no leading './' or '/', no trailing '/'. */
20
+ export function cleanEntryPath(p) {
21
+ return p.replace(/^(\.\/|\/)+/, '').replace(/\/+$/, '') || '.'
22
+ }
23
+
24
+ /**
25
+ * Every entry of a layer tar (plain or gzip) as {path, type, mode, uid, gid, size, sha256, linkname, xattrs, mtime}
26
+ * in archive order. `sha256` is the file's content for regular files, else null; `mode` the permission bits (with
27
+ * setuid/setgid/sticky).
28
+ */
29
+ export async function layerEntries(bytes) {
30
+ const u8 = await maybeGunzip(bytes)
31
+ const out = []
32
+ for await (const e of scanTarBytes(u8)) {
33
+ const type = TYPES[e.type] || `type-${e.type}`
34
+ out.push({
35
+ path: cleanEntryPath(e.name),
36
+ type,
37
+ mode: e.mode & 0o7777,
38
+ uid: e.uid,
39
+ gid: e.gid,
40
+ size: type === 'file' ? e.size : 0,
41
+ sha256: type === 'file' ? await sha256Hex(u8.subarray(e.offset, e.offset + e.size)) : null,
42
+ linkname: e.linkname,
43
+ xattrs: e.xattrs || {},
44
+ mtime: e.mtime,
45
+ })
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * A Docker tar (what BuildKit's type=docker output writes: a `docker save` layout with manifest.json, or an OCI
52
+ * layout with index.json) as {config, layers}: `config` the image config JSON, `layers` the layer tars' bytes
53
+ * bottom first, as stored (plain or gzip).
54
+ */
55
+ export async function dockerTarLayers(bytes) {
56
+ const files = new Map()
57
+ for await (const e of scanTarBytes(bytes)) {
58
+ if (e.type === '0' || e.type === '7') files.set(cleanEntryPath(e.name), bytes.subarray(e.offset, e.offset + e.size))
59
+ }
60
+ const get = (p) => {
61
+ const f = files.get(cleanEntryPath(p))
62
+ if (!f) throw new Error(`the Docker tar has no ${p}`)
63
+ return f
64
+ }
65
+ const json = (p) => JSON.parse(dec.decode(get(p)))
66
+ const blob = (digest) => get(`blobs/${digest.replace(':', '/')}`)
67
+ if (files.has('manifest.json')) {
68
+ const m = json('manifest.json')[0]
69
+ return { config: json(m.Config), layers: m.Layers.map((l) => get(l)) }
70
+ }
71
+ if (files.has('index.json')) {
72
+ let m = json('index.json').manifests[0]
73
+ let man = JSON.parse(dec.decode(blob(m.digest)))
74
+ if (man.manifests) man = JSON.parse(dec.decode(blob(man.manifests[0].digest)))
75
+ return { config: JSON.parse(dec.decode(blob(man.config.digest))), layers: man.layers.map((l) => blob(l.digest)) }
76
+ }
77
+ throw new Error('the tar has neither manifest.json nor index.json')
78
+ }
@@ -0,0 +1,6 @@
1
+ // The wmd-web builder images (scripts/web-builder.sh writes this file): the sha256 and size of each
2
+ // `docker save` tar in web/dist/builder/. createBuilder refuses a tar that does not match.
3
+ export const BUILDER_PINS = {
4
+ arm64: { file: 'builder-arm64.tar', sha256: '905d0735a2fb7aa12f00bb21f50e82651d1f65db0aa030aa38ab4033047072ee', bytes: 107119104 },
5
+ amd64: { file: 'builder-amd64.tar', sha256: 'be2199d5eb05d1bcc2814999749de17d4e4ce206726f31fe8a495cf602fd3ccd', bytes: 113018368 },
6
+ }
@@ -0,0 +1,304 @@
1
+ // A small deterministic tar writer and header scanner (docs/plans/web-buildkit-impl.md 5.6).
2
+ // Output is ustar; a pax extended header (typeflag 'x') carries what ustar cannot hold: names over the
3
+ // name+prefix limits, non-ASCII names, link targets over 100 bytes, sizes of 8 GiB or more, and uid/gid/mtime
4
+ // that overflow their octal fields. Nothing reads the host clock: mtime is what the caller gives, else 0.
5
+
6
+ const enc = new TextEncoder()
7
+ const dec = new TextDecoder()
8
+ const BLOCK = 512
9
+
10
+ /** TextDecoder.decode that also takes a view on a SharedArrayBuffer (wasm memory), which browsers' decoders refuse. */
11
+ export function decodeText(u8, d = dec) {
12
+ return typeof SharedArrayBuffer !== 'undefined' && u8.buffer instanceof SharedArrayBuffer ? d.decode(u8.slice()) : d.decode(u8)
13
+ }
14
+
15
+ /** Throws on a path that must not enter an archive: absolute, '..', NUL, empty components. */
16
+ export function checkTarPath(path) {
17
+ if (path === './') return // the archive's root folder entry
18
+ if (typeof path !== 'string' || path === '') throw new Error('tar: empty path')
19
+ if (path.includes('\0')) throw new Error(`tar: NUL in path ${JSON.stringify(path)}`)
20
+ if (path.startsWith('/')) throw new Error(`tar: absolute path ${path}`)
21
+ const parts = path.split('/')
22
+ if (parts[parts.length - 1] === '') parts.pop() // one trailing '/' is a directory spelling
23
+ for (const p of parts) {
24
+ if (p === '' || p === '.' || p === '..') throw new Error(`tar: unsafe path ${path}`)
25
+ }
26
+ }
27
+
28
+ function isAscii(s) {
29
+ for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 0x7f) return false
30
+ return true
31
+ }
32
+
33
+ function toSeconds(t) {
34
+ if (t === undefined || t === null) return 0
35
+ if (t instanceof Date) return Math.floor(t.getTime() / 1000)
36
+ return Math.max(0, Math.floor(Number(t) || 0))
37
+ }
38
+
39
+ function octal(buf, off, len, value) {
40
+ const s = value.toString(8).padStart(len - 1, '0')
41
+ for (let i = 0; i < len - 1; i++) buf[off + i] = s.charCodeAt(i)
42
+ buf[off + len - 1] = 0
43
+ }
44
+ const octMax = (len) => 8 ** (len - 1) - 1
45
+
46
+ function putStr(buf, off, str) {
47
+ for (let i = 0; i < str.length; i++) buf[off + i] = str.charCodeAt(i)
48
+ }
49
+
50
+ /** One pax record: "<len> <key>=<value>\n" where len counts itself. */
51
+ function paxRecord(key, value) {
52
+ const body = enc.encode(` ${key}=${value}\n`)
53
+ let n = body.length + 1
54
+ while (String(n).length + body.length !== n) n = String(n).length + body.length
55
+ const head = enc.encode(String(n))
56
+ const out = new Uint8Array(n)
57
+ out.set(head, 0)
58
+ out.set(body, head.length)
59
+ return out
60
+ }
61
+
62
+ function concat(parts, total) {
63
+ const out = new Uint8Array(total ?? parts.reduce((a, p) => a + p.length, 0))
64
+ let o = 0
65
+ for (const p of parts) {
66
+ out.set(p, o)
67
+ o += p.length
68
+ }
69
+ return out
70
+ }
71
+
72
+ function rawHeader(name, prefix, typeflag, size, mode, mtime, uid, gid, linkname) {
73
+ const h = new Uint8Array(BLOCK)
74
+ putStr(h, 0, name)
75
+ octal(h, 100, 8, mode)
76
+ octal(h, 108, 8, uid)
77
+ octal(h, 116, 8, gid)
78
+ octal(h, 124, 12, size)
79
+ octal(h, 136, 12, mtime)
80
+ h.fill(0x20, 148, 156)
81
+ h[156] = typeflag.charCodeAt(0)
82
+ putStr(h, 157, linkname)
83
+ putStr(h, 257, 'ustar')
84
+ h[262] = 0
85
+ h[263] = 0x30
86
+ h[264] = 0x30
87
+ putStr(h, 345, prefix)
88
+ let sum = 0
89
+ for (let i = 0; i < BLOCK; i++) sum += h[i]
90
+ const cs = sum.toString(8).padStart(6, '0')
91
+ putStr(h, 148, cs)
92
+ h[154] = 0
93
+ h[155] = 0x20
94
+ return h
95
+ }
96
+
97
+ /**
98
+ * The header block(s) of one entry: a pax header first when needed, then the ustar header.
99
+ * Exported so the over-8-GiB size path can be tested without 8 GiB of data.
100
+ * @returns {Uint8Array}
101
+ */
102
+ export function tarEntryHeader(path, typeflag, size, { mode, mtime, uid = 0, gid = 0, linkname = '' } = {}) {
103
+ checkTarPath(path)
104
+ const isDir = typeflag === '5'
105
+ const full = isDir && !path.endsWith('/') ? `${path}/` : path
106
+ const pax = []
107
+ let name = full
108
+ let prefix = ''
109
+ const nameBytes = enc.encode(full)
110
+ if (isAscii(full) && nameBytes.length <= 100) {
111
+ // plain
112
+ } else if (isAscii(full) && nameBytes.length <= 255 + 1 + 100) {
113
+ // ustar prefix/name split at a '/'
114
+ let at = -1
115
+ for (let i = full.length - 2; i > 0; i--) {
116
+ if (full[i] !== '/') continue
117
+ if (full.length - i - 1 <= 100 && i <= 155) {
118
+ at = i
119
+ break
120
+ }
121
+ }
122
+ if (at > 0) {
123
+ prefix = full.slice(0, at)
124
+ name = full.slice(at + 1)
125
+ } else pax.push(['path', full])
126
+ } else pax.push(['path', full])
127
+ if (pax.length) {
128
+ // ustar fallback name: ASCII-only tail, never empty
129
+ name = full.replace(/[^\x20-\x7e]/g, '_').slice(-100)
130
+ prefix = ''
131
+ }
132
+ let link = linkname
133
+ if (link && (!isAscii(link) || enc.encode(link).length > 100)) {
134
+ pax.push(['linkpath', link])
135
+ link = link.replace(/[^\x20-\x7e]/g, '_').slice(0, 100)
136
+ }
137
+ const m = (mode ?? (isDir ? 0o755 : 0o644)) & 0o7777
138
+ let t = toSeconds(mtime)
139
+ if (t > octMax(12)) {
140
+ pax.push(['mtime', String(t)])
141
+ t = octMax(12)
142
+ }
143
+ let u = uid
144
+ if (u > octMax(8)) {
145
+ pax.push(['uid', String(u)])
146
+ u = octMax(8)
147
+ }
148
+ let g = gid
149
+ if (g > octMax(8)) {
150
+ pax.push(['gid', String(g)])
151
+ g = octMax(8)
152
+ }
153
+ let sz = size
154
+ if (sz > octMax(12)) {
155
+ pax.push(['size', String(sz)])
156
+ sz = octMax(12)
157
+ }
158
+ const main = rawHeader(name, prefix, typeflag, sz, m, t, u, g, link)
159
+ if (!pax.length) return main
160
+ const recs = pax.map(([k, v]) => paxRecord(k, v))
161
+ const dataLen = recs.reduce((a, r) => a + r.length, 0)
162
+ const padded = Math.ceil(dataLen / BLOCK) * BLOCK
163
+ const tail = (full.replace(/\/$/, '').split('/').pop() || 'x').replace(/[^\x20-\x7e]/g, '_').slice(-60)
164
+ const ph = rawHeader(`PaxHeaders.0/${tail}`, '', 'x', dataLen, 0o644, 0, 0, 0, '')
165
+ const body = new Uint8Array(padded)
166
+ body.set(concat(recs, dataLen), 0)
167
+ return concat([ph, body, main])
168
+ }
169
+
170
+ /** A tar writer. Paths are relative, '/'-separated, without '..' or a leading '/'. */
171
+ export function tarWriter() {
172
+ const chunks = []
173
+ let total = 0
174
+ let done = false
175
+ const push = (c) => {
176
+ if (done) throw new Error('tar: writer already finished')
177
+ chunks.push(c)
178
+ total += c.length
179
+ }
180
+ const pad = (n) => (BLOCK - (n % BLOCK)) % BLOCK
181
+ return {
182
+ file(path, bytes, { mode, mtime, uid, gid } = {}) {
183
+ const data = typeof bytes === 'string' ? enc.encode(bytes) : bytes
184
+ push(tarEntryHeader(path, '0', data.length, { mode, mtime, uid, gid }))
185
+ if (data.length) {
186
+ push(data)
187
+ const p = pad(data.length)
188
+ if (p) push(new Uint8Array(p))
189
+ }
190
+ },
191
+ dir(path, { mode, mtime, uid, gid } = {}) {
192
+ push(tarEntryHeader(path, '5', 0, { mode, mtime, uid, gid }))
193
+ },
194
+ symlink(path, target, { mtime, uid, gid } = {}) {
195
+ if (!target) throw new Error('tar: empty symlink target')
196
+ push(tarEntryHeader(path, '2', 0, { mode: 0o777, mtime, uid, gid, linkname: target }))
197
+ },
198
+ /** The archive so far, ended with two zero blocks. */
199
+ finish() {
200
+ if (done) throw new Error('tar: writer already finished')
201
+ push(new Uint8Array(BLOCK * 2))
202
+ done = true
203
+ return concat(chunks, total)
204
+ },
205
+ }
206
+ }
207
+
208
+ function parseNum(buf, off, len) {
209
+ if (buf[off] & 0x80) {
210
+ // GNU base-256
211
+ let v = buf[off] & 0x7f
212
+ for (let i = 1; i < len; i++) v = v * 256 + buf[off + i]
213
+ return v
214
+ }
215
+ let s = ''
216
+ for (let i = 0; i < len && buf[off + i] !== 0 && buf[off + i] !== 0x20; i++) s += String.fromCharCode(buf[off + i])
217
+ return s ? parseInt(s, 8) : 0
218
+ }
219
+
220
+ function cstr(buf, off, len) {
221
+ let e = off
222
+ while (e < off + len && buf[e] !== 0) e++
223
+ return decodeText(buf.subarray(off, e))
224
+ }
225
+
226
+ function parsePax(data) {
227
+ const out = {}
228
+ let p = 0
229
+ while (p < data.length) {
230
+ let sp = p
231
+ while (sp < data.length && data[sp] !== 0x20) sp++
232
+ const n = parseInt(decodeText(data.subarray(p, sp)), 10)
233
+ if (!(n > 0) || p + n > data.length) break
234
+ const rec = decodeText(data.subarray(sp + 1, p + n - 1))
235
+ const eq = rec.indexOf('=')
236
+ if (eq > 0) out[rec.slice(0, eq)] = rec.slice(eq + 1)
237
+ p += n
238
+ }
239
+ return out
240
+ }
241
+
242
+ /**
243
+ * Walks a tar's headers without reading file data. `read(offset, length)` returns a Uint8Array (sync or
244
+ * promise) so a Blob can be walked by slices. Yields real entries only (pax 'x'/'g' and GNU 'L'/'K' are folded
245
+ * into the entry they describe): {name, type, size, mode, mtime, uid, gid, linkname, xattrs, offset} where `offset` is
246
+ * the start of the entry's data. `type` is the typeflag character ('0' for the old NUL flag).
247
+ */
248
+ export async function* scanTar(read, totalLength) {
249
+ let off = 0
250
+ let pax = null
251
+ let gnuName = null
252
+ let gnuLink = null
253
+ const need = async (o, n) => {
254
+ const b = await read(o, n)
255
+ if (b.length < n) throw new Error('tar: truncated archive')
256
+ return b
257
+ }
258
+ while (off + BLOCK <= totalLength) {
259
+ const h = await need(off, BLOCK)
260
+ if (h.every((b) => b === 0)) return
261
+ let sum = 0
262
+ for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 0x20 : h[i]
263
+ if (sum !== parseNum(h, 148, 8)) throw new Error(`tar: bad header checksum at ${off}`)
264
+ const type = h[156] === 0 ? '0' : String.fromCharCode(h[156])
265
+ let size = parseNum(h, 124, 12)
266
+ off += BLOCK
267
+ if (type === 'x' || type === 'g' || type === 'L' || type === 'K') {
268
+ const data = await need(off, size)
269
+ off += Math.ceil(size / BLOCK) * BLOCK
270
+ if (type === 'x') pax = { ...(pax || {}), ...parsePax(data) }
271
+ else if (type === 'L') gnuName = cstr(data, 0, data.length)
272
+ else if (type === 'K') gnuLink = cstr(data, 0, data.length)
273
+ continue
274
+ }
275
+ const prefix = cstr(h, 345, 155)
276
+ let name = cstr(h, 0, 100)
277
+ if (prefix) name = `${prefix}/${name}`
278
+ let linkname = cstr(h, 157, 100)
279
+ let mtime = parseNum(h, 136, 12)
280
+ let uid = parseNum(h, 108, 8)
281
+ let gid = parseNum(h, 116, 8)
282
+ if (gnuName !== null) name = gnuName
283
+ if (gnuLink !== null) linkname = gnuLink
284
+ if (pax) {
285
+ if (pax.path !== undefined) name = pax.path
286
+ if (pax.linkpath !== undefined) linkname = pax.linkpath
287
+ if (pax.size !== undefined) size = Number(pax.size)
288
+ if (pax.mtime !== undefined) mtime = Math.floor(Number(pax.mtime))
289
+ if (pax.uid !== undefined) uid = Number(pax.uid)
290
+ if (pax.gid !== undefined) gid = Number(pax.gid)
291
+ }
292
+ const xattrs = {}
293
+ if (pax) for (const [k, v] of Object.entries(pax)) if (k.startsWith('SCHILY.xattr.')) xattrs[k.slice(13)] = v
294
+ pax = gnuName = gnuLink = null
295
+ const hasData = type === '0' || type === '7' || (type !== '1' && type !== '2' && type !== '5' && size > 0)
296
+ yield { name, type, size, mode: parseNum(h, 100, 8), mtime, uid, gid, linkname, xattrs, offset: off }
297
+ if (hasData) off += Math.ceil(size / BLOCK) * BLOCK
298
+ }
299
+ }
300
+
301
+ /** scanTar over a Uint8Array. */
302
+ export function scanTarBytes(u8) {
303
+ return scanTar((o, n) => u8.subarray(o, o + n), u8.length)
304
+ }
@@ -30,27 +30,44 @@ export const REQ_CKPT = VM_POINT_CKPT
30
30
 
31
31
  /** Pages per chunk of the memory section (32 MiB). */
32
32
  export const CHUNK_PAGES = 512
33
+ /** Pages per view while a memory is scanned (1 GiB). */
34
+ export const WINDOW_PAGES = 16384
33
35
 
34
36
  /**
35
37
  * Non-zero pages of memory: {index: Uint32Array of page numbers, chunks: ArrayBuffer[] (pages
36
38
  * back to back, CHUNK_PAGES each), pages (total), nonzero, scanMs, copyMs}. Reads the memory
37
39
  * without atomics: the machine is frozen.
38
40
  */
39
- export function scanMemory(memory, { now = () => performance.now(), chunkPages = CHUNK_PAGES, limitBytes = Infinity, verifyAbove = false } = {}) {
41
+ export function scanMemory(memory, { now = () => performance.now(), chunkPages = CHUNK_PAGES, limitBytes = Infinity, verifyAbove = false, skip = null } = {}) {
40
42
  const buf = memory.buffer
41
43
  // limitBytes: pages from here up are taken to be zero and are not read. Reading an untouched page of a shared
42
44
  // memory commits it in WebKit (W34: a full walk of a 3968 MiB memory added about 2.2 GB of footprint), so a caller
43
45
  // that knows where the guest's heap ends should say so; the page count of the checkpoint stays the memory's.
44
46
  const total = buf.byteLength / PAGE
45
47
  const pages = Math.min(total, Math.ceil(limitBytes / PAGE))
46
- const w = new Int32Array(buf)
47
- const u8 = new Uint8Array(buf)
48
48
  const per = PAGE / 4
49
+ // views of at most WINDOW_PAGES pages: a guest RAM memory64 can be 16 GiB, past the length of one typed array view
50
+ let wLo = -1
51
+ let w = null
52
+ const words = p => {
53
+ const at = p - (p % WINDOW_PAGES)
54
+ if (at !== wLo) {
55
+ wLo = at
56
+ w = new Int32Array(buf, at * PAGE, Math.min(WINDOW_PAGES, total - at) * per)
57
+ }
58
+ return (p - at) * per
59
+ }
49
60
  const t0 = now()
50
61
  let idx = new Uint32Array(Math.min(pages, 1 << 14))
51
62
  let n = 0
63
+ let skipped = 0
52
64
  for (let p = 0; p < pages; p++) {
53
- const lo = p * per
65
+ // a page the guest never wrote is zero: it is not read (reading it would make the host back it with memory)
66
+ if (skip && skip(p)) {
67
+ skipped++
68
+ continue
69
+ }
70
+ const lo = words(p)
54
71
  const hi = lo + per
55
72
  let nz = false
56
73
  for (let i = lo; i < hi; i += 8) {
@@ -72,7 +89,7 @@ export function scanMemory(memory, { now = () => performance.now(), chunkPages =
72
89
  let aboveNonzero = 0
73
90
  if (verifyAbove) {
74
91
  for (let p = pages; p < total; p++) {
75
- const lo = p * per
92
+ const lo = words(p)
76
93
  for (let i = lo; i < lo + per; i += 8) {
77
94
  if ((w[i] | w[i + 1] | w[i + 2] | w[i + 3] | w[i + 4] | w[i + 5] | w[i + 6] | w[i + 7]) !== 0) {
78
95
  aboveNonzero++
@@ -86,10 +103,32 @@ export function scanMemory(memory, { now = () => performance.now(), chunkPages =
86
103
  for (let k = 0; k < n; k += chunkPages) {
87
104
  const m = Math.min(chunkPages, n - k)
88
105
  const c = new Uint8Array(m * PAGE)
89
- for (let j = 0; j < m; j++) c.set(u8.subarray(idx[k + j] * PAGE, (idx[k + j] + 1) * PAGE), j * PAGE)
106
+ for (let j = 0; j < m; j++) c.set(new Uint8Array(buf, idx[k + j] * PAGE, PAGE), j * PAGE)
90
107
  chunks.push(c.buffer)
91
108
  }
92
- return { index: idx.slice(0, n), chunks, pages: total, scannedPages: pages, aboveNonzero, nonzero: n, scanMs: t1 - t0, copyMs: now() - t1 }
109
+ return { index: idx.slice(0, n), chunks, pages: total, scannedPages: pages - skipped, skippedPages: skipped, aboveNonzero, nonzero: n, scanMs: t1 - t0, copyMs: now() - t1 }
110
+ }
111
+
112
+ /**
113
+ * The pages a checkpoint need not read, from vm.wasm's vm_touched_map (guest/vm/include/vm/ram.h): the RAM pages the guest never
114
+ * wrote. `desc` is the address of its six u64s: [bitmap address, bitmap pages, memory-0 offset of RAM (or of the flat layout's boot
115
+ * stand-in), its length, RAM bytes, flat]. Returns {mem0(p), ram(p)}: whether page p of memory 0, or of the RAM memory, is skipped.
116
+ */
117
+ export function touchedSkips(memory, desc) {
118
+ const d = new BigUint64Array(memory.buffer, desc, 6)
119
+ const bits = new Uint8Array(memory.buffer, Number(d[0]), Number(d[1]) / 8)
120
+ const nbits = Number(d[1])
121
+ const lowOff = Number(d[2])
122
+ const lowLen = Number(d[3])
123
+ const flat = d[5] !== 0n
124
+ const unset = i => i >= nbits || !(bits[i >> 3] & (1 << (i & 7)))
125
+ const first = Math.ceil(lowOff / PAGE)
126
+ const end = Math.floor((lowOff + lowLen) / PAGE) // pages wholly inside the range
127
+ return {
128
+ // memory 0: a page inside guest RAM that was never written; with the flat layout the boot stand-in is never read again
129
+ mem0: p => p >= first && p < end && (flat || unset(p - lowOff / PAGE)),
130
+ ram: p => unset(p),
131
+ }
93
132
  }
94
133
 
95
134
  /**
@@ -97,7 +136,7 @@ export function scanMemory(memory, { now = () => performance.now(), chunkPages =
97
136
  * message to the page; goTimeoutMs: how long vCPU 0 waits for the page (a page that is gone
98
137
  * makes the point fail: the guest goes on, as it does for any failed point).
99
138
  */
100
- export function createCkptImports({ memory, ctl, post, scanLimit = null, verifyBound = false, useHeapTop = false, heapTop = () => null, goTimeoutMs = 60_000, now = () => performance.timeOrigin + performance.now() }) {
139
+ export function createCkptImports({ memory, ram = null, touched = () => null, ctl, post, scanLimit = null, verifyBound = false, useHeapTop = false, heapTop = () => null, goTimeoutMs = 60_000, now = () => performance.timeOrigin + performance.now() }) {
101
140
  return {
102
141
  ctl_attach(wordAddr) {
103
142
  Atomics.store(ctl, CTL_CK_HOSTREQ, wordAddr >>> 0)
@@ -124,7 +163,11 @@ export function createCkptImports({ memory, ctl, post, scanLimit = null, verifyB
124
163
  // verifyBound found no non-zero page above it
125
164
  const top = scanLimit ?? (useHeapTop ? heapTop() : null)
126
165
  const limit = typeof top === 'number' && top > 0 ? Math.min(top, memory.buffer.byteLength) : Infinity
127
- cap = scanMemory(memory, { now, limitBytes: limit, verifyAbove: verifyBound })
166
+ const desc = touched()
167
+ const skips = desc ? touchedSkips(memory, desc) : null
168
+ // RAM in memory 0 starts at a 64 KiB boundary (memory.grow), so a skipped page is all RAM
169
+ if (skips && (Number(new BigUint64Array(memory.buffer, desc, 6)[2]) % PAGE) !== 0) throw new Error('guest RAM is not page-aligned in memory 0')
170
+ cap = scanMemory(memory, { now, limitBytes: limit, verifyAbove: verifyBound, skip: skips ? skips.mem0 : null })
128
171
  cap.limitBytes = Number.isFinite(limit) ? limit : null
129
172
  // the live g_rsv {brk, end} of vm.wasm 50c6495e / 3aef93fc (linear address 4251160), next to what vm_heap_top() says
130
173
  try {
@@ -133,13 +176,15 @@ export function createCkptImports({ memory, ctl, post, scanLimit = null, verifyB
133
176
  cap.endLive = Number(g[1])
134
177
  } catch {}
135
178
  cap.heapTopRet = heapTop()
179
+ // the flat layout: guest RAM is a memory of its own (host/ram-mem.js), every page of it the guest's
180
+ if (ram) cap.ram = scanMemory(ram, { now, skip: skips ? skips.ram : null })
136
181
  } catch (err) {
137
182
  post({ t: 'ckpt-mem-error', message: String(err && err.message ? err.message : err) })
138
183
  Atomics.wait(ctl, CTL_CK_GO, 0, goTimeoutMs)
139
184
  Atomics.store(ctl, CTL_CK_GO, 0)
140
185
  return -12
141
186
  }
142
- post({ t: 'ckpt-mem', index: cap.index, chunks: cap.chunks, pages: cap.pages, scannedPages: cap.scannedPages, aboveNonzero: cap.aboveNonzero, brkLive: cap.brkLive, endLive: cap.endLive, heapTopRet: cap.heapTopRet, limitBytes: cap.limitBytes, nonzero: cap.nonzero, scanMs: cap.scanMs, copyMs: cap.copyMs, doneAt: now() }, [cap.index.buffer, ...cap.chunks])
187
+ post({ t: 'ckpt-mem', index: cap.index, chunks: cap.chunks, pages: cap.pages, scannedPages: cap.scannedPages, skippedPages: cap.skippedPages, ramScannedPages: cap.ram?.scannedPages, ramSkippedPages: cap.ram?.skippedPages, aboveNonzero: cap.aboveNonzero, brkLive: cap.brkLive, endLive: cap.endLive, heapTopRet: cap.heapTopRet, limitBytes: cap.limitBytes, nonzero: cap.nonzero, scanMs: cap.scanMs, copyMs: cap.copyMs, ram: cap.ram ? { index: cap.ram.index, chunks: cap.ram.chunks, pages: cap.ram.pages, nonzero: cap.ram.nonzero, scanMs: cap.ram.scanMs, copyMs: cap.ram.copyMs } : null, doneAt: now() }, [cap.index.buffer, ...cap.chunks, ...(cap.ram ? [cap.ram.index.buffer, ...cap.ram.chunks] : [])])
143
188
  const r = Atomics.wait(ctl, CTL_CK_GO, 0, goTimeoutMs)
144
189
  const go = Atomics.load(ctl, CTL_CK_GO)
145
190
  Atomics.store(ctl, CTL_CK_GO, 0)
package/src/host/ckpt.js CHANGED
@@ -154,6 +154,11 @@ export function assembleCheckpoint(p) {
154
154
  const sections = []
155
155
  sections.push({ name: 'mem-index', chunks: [new Uint8Array(p.mem.index.buffer, p.mem.index.byteOffset, p.mem.index.byteLength)] })
156
156
  sections.push({ name: 'mem', chunks: p.mem.chunks.map(c => new Uint8Array(c)) })
157
+ // the flat layout: the guest's RAM is a memory of its own, saved as its non-zero pages the same way
158
+ if (p.mem.ram) {
159
+ sections.push({ name: 'ram-index', chunks: [new Uint8Array(p.mem.ram.index.buffer, p.mem.ram.index.byteOffset, p.mem.ram.index.byteLength)] })
160
+ sections.push({ name: 'ram', chunks: p.mem.ram.chunks.map(c => new Uint8Array(c)) })
161
+ }
157
162
  const header = {
158
163
  ...p.meta,
159
164
  format: FORMAT_VERSION,
@@ -164,6 +169,10 @@ export function assembleCheckpoint(p) {
164
169
  guestHdr: b64(p.hdr),
165
170
  svcPorts: Object.keys(p.svc).map(Number),
166
171
  }
172
+ if (p.mem.ram) {
173
+ header.ramPages = p.mem.ram.pages
174
+ header.ramNonzero = p.mem.ram.nonzero
175
+ }
167
176
  if (p.jit) {
168
177
  sections.push({ name: 'jit-recs', chunks: [new Uint8Array(p.jit.snap.recs.buffer, p.jit.snap.recs.byteOffset, p.jit.snap.recs.byteLength)] })
169
178
  sections.push({ name: 'jit-log', chunks: concatChunks([p.jit.snap.log], 32 << 20) })
@@ -198,6 +207,7 @@ export function parseCheckpoint(ck) {
198
207
  if (index[i] >= h.memPages) throw new CheckpointError('malformed', `page ${index[i]} is past the ${h.memPages} pages of memory`)
199
208
  if (i && index[i] <= index[i - 1]) throw new CheckpointError('malformed', `the page list is not strictly increasing at entry ${i} (page ${index[i]} after ${index[i - 1]}): a duplicate or unordered page`)
200
209
  }
210
+ const ram = h.ramPages ? pagesOf(get, 'ram', h.ramPages) : null
201
211
  let jit = null
202
212
  if (h.jit) {
203
213
  const recs = concat(get('jit-recs'))
@@ -213,7 +223,26 @@ export function parseCheckpoint(ck) {
213
223
  const svc = {}
214
224
  for (const port of h.svcPorts || []) svc[port] = concat(get(`svc-${port}`))
215
225
  const ctlBytes = concat(get('ctl'))
216
- return { header: h, index, memChunks, jit, svc, ctl: new Uint8Array(ctlBytes), names: parseNames(ck) }
226
+ return { header: h, index, memChunks, ram, jit, svc, ctl: new Uint8Array(ctlBytes), names: parseNames(ck) }
227
+ }
228
+
229
+ /** The flat layout's RAM sections (`<name>-index` and `<name>`): {index, chunks}, checked as the memory's are. */
230
+ function pagesOf(get, name, total) {
231
+ if (!Number.isInteger(total) || total <= 0) throw new CheckpointError('malformed', `${name}Pages is ${total}`)
232
+ const idxBytes = concat(get(`${name}-index`))
233
+ if (idxBytes.length % 4) throw new CheckpointError('malformed', `${name}-index is not a list of 32-bit page numbers`)
234
+ const index = new Uint32Array(idxBytes.buffer.slice(idxBytes.byteOffset, idxBytes.byteOffset + idxBytes.length))
235
+ const chunks = get(name)
236
+ const have = chunks.reduce((n, c) => n + c.length, 0)
237
+ if (have !== index.length * PAGE) throw new CheckpointError('malformed', `the ${name} section holds ${have} bytes for ${index.length} pages`)
238
+ chunks.forEach((c, i) => {
239
+ if (c.length % PAGE) throw new CheckpointError('malformed', `${name} chunk ${i} is ${c.length} bytes, not a whole number of ${PAGE}-byte pages`)
240
+ })
241
+ for (let i = 0; i < index.length; i++) {
242
+ if (index[i] >= total) throw new CheckpointError('malformed', `${name} page ${index[i]} is past its ${total} pages`)
243
+ if (i && index[i] <= index[i - 1]) throw new CheckpointError('malformed', `the ${name} page list is not strictly increasing at entry ${i}`)
244
+ }
245
+ return { pages: total, index, chunks }
217
246
  }
218
247
 
219
248
  // no leading zeros: "198.18.07.1" and "198.18.7.1" are one address, and the map keeps each address once
@@ -266,7 +295,7 @@ export function checkVmKeyFits(h, vmKey) {
266
295
  }
267
296
  }
268
297
 
269
- export function checkCheckpointFits(h, { vmKey, bootKey, bootDesc, maxPages, hasJit, fmaFused = true }) {
298
+ export function checkCheckpointFits(h, { vmKey, bootKey, bootDesc, maxPages, ramPages = 0, hasJit, fmaFused = true }) {
270
299
  if (h.format !== FORMAT_VERSION) throw new CheckpointError('version', `checkpoint format ${h.format}; this build restores format ${FORMAT_VERSION}`)
271
300
  if (h.vmKey !== vmKey) {
272
301
  throw new CheckpointError('stale', `this checkpoint was taken with a different vm.wasm (${String(h.vmKey).slice(0, 12)}, this boot has ${String(vmKey).slice(0, 12)}): its memory image is the old emulator's and cannot be restored; take a new one`)
@@ -276,6 +305,10 @@ export function checkCheckpointFits(h, { vmKey, bootKey, bootDesc, maxPages, has
276
305
  throw new CheckpointError('stale', `this checkpoint was taken by a boot with a different kernel, initramfs, command line, environment, image layers or bundle files than this one; restore it only into the same configuration${diff ? ` (differs: ${diff})` : ''}`)
277
306
  }
278
307
  if (h.memPages > maxPages) throw new CheckpointError('stale', `this checkpoint's memory is ${h.memPages} pages and this boot's maximum is ${maxPages}`)
308
+ // the flat layout: the guest's RAM is restored into a memory of exactly its size
309
+ if ((h.ramPages || 0) !== ramPages) {
310
+ throw new CheckpointError('stale', h.ramPages ? `this checkpoint holds ${h.ramPages / 16} MiB of guest RAM in a memory of its own and this boot ${ramPages ? `has ${ramPages / 16} MiB` : 'keeps its RAM in memory 0'}` : 'this checkpoint keeps the guest\'s RAM in memory 0 and this boot keeps it in a memory of its own')
311
+ }
279
312
  if (h.jit && !hasJit) throw new CheckpointError('stale', 'this checkpoint carries JIT state and this boot has the JIT door off (jit: false)')
280
313
  if (!h.jit && hasJit) throw new CheckpointError('stale', 'this checkpoint was taken without the JIT door and this boot has it on')
281
314
  // D-539: its JIT blocks may use relaxed_madd, which this engine does not fuse or parse
package/src/host/ctl.js CHANGED
@@ -719,6 +719,14 @@ export const DEFAULT_CAPS = [
719
719
  'CAP_CHOWN', 'CAP_DAC_OVERRIDE', 'CAP_FSETID', 'CAP_FOWNER', 'CAP_MKNOD', 'CAP_NET_RAW', 'CAP_SETGID',
720
720
  'CAP_SETUID', 'CAP_SETFCAP', 'CAP_SETPCAP', 'CAP_NET_BIND_SERVICE', 'CAP_SYS_CHROOT', 'CAP_KILL', 'CAP_AUDIT_WRITE',
721
721
  ]
722
+ /** Every capability, as internal/engine/ocispec.go allCaps (Linux 6.1: 0..40); what --privileged grants. */
723
+ export const ALL_CAPS = [
724
+ 'CHOWN', 'DAC_OVERRIDE', 'DAC_READ_SEARCH', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID', 'SETPCAP', 'LINUX_IMMUTABLE',
725
+ 'NET_BIND_SERVICE', 'NET_BROADCAST', 'NET_ADMIN', 'NET_RAW', 'IPC_LOCK', 'IPC_OWNER', 'SYS_MODULE', 'SYS_RAWIO', 'SYS_CHROOT',
726
+ 'SYS_PTRACE', 'SYS_PACCT', 'SYS_ADMIN', 'SYS_BOOT', 'SYS_NICE', 'SYS_RESOURCE', 'SYS_TIME', 'SYS_TTY_CONFIG', 'MKNOD', 'LEASE',
727
+ 'AUDIT_WRITE', 'AUDIT_CONTROL', 'SETFCAP', 'MAC_OVERRIDE', 'MAC_ADMIN', 'SYSLOG', 'WAKE_ALARM', 'BLOCK_SUSPEND', 'AUDIT_READ',
728
+ 'PERFMON', 'BPF', 'CHECKPOINT_RESTORE',
729
+ ].map(c => `CAP_${c}`)
722
730
  const MASKED = ['/proc/acpi', '/proc/asound', '/proc/kcore', '/proc/keys', '/proc/latency_stats', '/proc/timer_list', '/proc/timer_stats', '/proc/sched_debug', '/proc/scsi', '/sys/firmware']
723
731
  const READONLY = ['/proc/bus', '/proc/fs', '/proc/irq', '/proc/sys', '/proc/sysrq-trigger']
724
732
  const ETC_FILES = ['hosts', 'resolv.conf', 'hostname']
@@ -751,6 +759,8 @@ function defaultMounts() {
751
759
  * @param {{destination:string, files:Record<string,{data:Uint8Array|string, mode?:number}|string>, file?:string, readOnly?:boolean}[]} [o.mounts]
752
760
  * each mount is a share vol<i> with these files; `file` binds only that file of the share
753
761
  * @param {string} [o.arch] for imageconfig.json (default arm64)
762
+ * @param {boolean} [o.privileged] docker run --privileged as dockerd writes it: all capabilities, no masked or
763
+ * read-only paths, /sys read-write, every device allowed (default false; only builder machines use it)
754
764
  * @param {boolean} [o.init] docker run --init: tini at /sbin/docker-init runs the command
755
765
  * @param {boolean} [o.keepRunning] wmd's keep-running init at /sbin/docker-init: the container stays up
756
766
  * after its command ends; args may be [] (nothing runs until ControlClient.startCommand). Send the
@@ -785,6 +795,13 @@ export function containerBundle(o) {
785
795
  resources: { devices: [{ allow: false, access: 'rwm' }] },
786
796
  },
787
797
  }
798
+ if (o.privileged) {
799
+ spec.process.capabilities = { bounding: ALL_CAPS, effective: ALL_CAPS, inheritable: ALL_CAPS, permitted: ALL_CAPS }
800
+ spec.linux.maskedPaths = []
801
+ spec.linux.readonlyPaths = []
802
+ spec.linux.resources = { devices: [{ allow: true, access: 'rwm' }] }
803
+ for (const m of spec.mounts) if (m.destination === '/sys') m.options = m.options.filter(x => x !== 'ro')
804
+ }
788
805
  if (o.init || o.keepRunning) {
789
806
  // as dockerd binds its docker-init (internal/engine/ocispec.go): the guest's tini, or its own init for keep-running
790
807
  spec.mounts.push({ destination: DOCKER_INIT, type: 'bind', source: o.keepRunning ? '/sbin/init' : '/sbin/tini', options: ['bind', 'ro'] })