@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.
@@ -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
+ }
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'] })