@threadedstack/wmd-web 0.1.17 → 0.1.19
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 +27 -11
- package/relay/main.ts +3 -3
- 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,349 @@
|
|
|
1
|
+
// The build context (docs/plans/web-buildkit-impl.md 5.6): turn a file source, a plain object or a tar into the
|
|
2
|
+
// one tar the page sends to the builder. .dockerignore is applied here with a port of moby's patternmatcher, so
|
|
3
|
+
// ignored files are never read; BuildKit applies it again and stays the source of truth.
|
|
4
|
+
import { matcher, parseDockerignore } from './dockerignore.js'
|
|
5
|
+
import { decodeText, scanTar, tarWriter } from './tar.js'
|
|
6
|
+
|
|
7
|
+
const MiB = 1024 * 1024
|
|
8
|
+
/** Defaults from the plan (section 3): wmn's old limits. */
|
|
9
|
+
export const DEFAULT_LIMITS = { bytes: 128 * MiB, files: 20000, fileBytes: 64 * MiB }
|
|
10
|
+
|
|
11
|
+
/** `reason`: 'too-big' | 'too-many' | 'file-too-big' | 'unsafe-path' | 'bad-dockerignore' | 'bad-source'. */
|
|
12
|
+
export class ContextError extends Error {
|
|
13
|
+
constructor(reason, path, message) {
|
|
14
|
+
super(message || `build context: ${reason}${path ? ` (${path})` : ''}`)
|
|
15
|
+
this.name = 'ContextError'
|
|
16
|
+
this.reason = reason
|
|
17
|
+
this.path = path
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const enc = new TextEncoder()
|
|
22
|
+
const dec = new TextDecoder()
|
|
23
|
+
const DOCKERIGNORE = '.dockerignore'
|
|
24
|
+
const DOCKERFILE = 'Dockerfile'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes a context path: drops '.' and empty components ('./a', 'a//b', 'a/'), rejects '..', absolute
|
|
28
|
+
* paths, backslash-free NUL-free. Returns null for the context root itself.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeContextPath(p) {
|
|
31
|
+
if (typeof p !== 'string') throw new ContextError('unsafe-path', String(p), 'build context: path is not a string')
|
|
32
|
+
if (p.includes('\0')) throw new ContextError('unsafe-path', p, 'build context: NUL in path')
|
|
33
|
+
if (p.startsWith('/')) throw new ContextError('unsafe-path', p, `build context: absolute path ${p}`)
|
|
34
|
+
const out = []
|
|
35
|
+
for (const part of p.split('/')) {
|
|
36
|
+
if (part === '' || part === '.') continue
|
|
37
|
+
if (part === '..') throw new ContextError('unsafe-path', p, `build context: '..' in path ${p}`)
|
|
38
|
+
out.push(part)
|
|
39
|
+
}
|
|
40
|
+
return out.length ? out.join('/') : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Path order by component, so a folder's whole subtree follows it ('d', 'd/a', 'd-x'), as `tar -c` writes: GNU tar
|
|
45
|
+
* sets a folder's mtime once a later entry is outside it, so a child written after that ('d-x' before 'd/a' in
|
|
46
|
+
* plain string order) would leave the folder with the time of the unpack.
|
|
47
|
+
*/
|
|
48
|
+
export function byComponents(a, b) {
|
|
49
|
+
const x = a.split('/')
|
|
50
|
+
const y = b.split('/')
|
|
51
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1
|
|
52
|
+
return x.length - y.length
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* True when a path goes through one of the context's symlinks. Symlinks themselves are kept whatever they point
|
|
57
|
+
* at, as the docker CLI sends them (BuildKit resolves them inside its own copy of the context); a path under one
|
|
58
|
+
* would write through the link when the builder unpacks the tar.
|
|
59
|
+
*/
|
|
60
|
+
function underLink(p, links) {
|
|
61
|
+
for (let i = p.indexOf('/'); i > 0; i = p.indexOf('/', i + 1)) if (links.has(p.slice(0, i))) return true
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function limitsOf(o) {
|
|
66
|
+
const l = { ...DEFAULT_LIMITS }
|
|
67
|
+
for (const k of ['bytes', 'files', 'fileBytes']) if (o && o[k] !== undefined) l[k] = o[k]
|
|
68
|
+
return l
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isBlob(x) {
|
|
72
|
+
return typeof Blob !== 'undefined' && x instanceof Blob
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isFileSource(x) {
|
|
76
|
+
return x && typeof x.list === 'function' && typeof x.stat === 'function' && typeof x.read === 'function'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Adapts {[path]: Uint8Array|string} to the file-source shape. A key ending in '/' is a directory. */
|
|
80
|
+
function objectSource(obj) {
|
|
81
|
+
const m = new Map()
|
|
82
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
83
|
+
if (k.endsWith('/')) {
|
|
84
|
+
m.set(k, null)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (typeof v === 'string') m.set(k, enc.encode(v))
|
|
88
|
+
else if (v instanceof Uint8Array) m.set(k, v)
|
|
89
|
+
else throw new ContextError('bad-source', k, `build context: value of ${k} is not a string or Uint8Array`)
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
async list() {
|
|
93
|
+
return [...m.keys()]
|
|
94
|
+
},
|
|
95
|
+
async stat(p) {
|
|
96
|
+
const v = m.get(p)
|
|
97
|
+
if (v === undefined) throw new ContextError('bad-source', p, `build context: no such path ${p}`)
|
|
98
|
+
return v === null ? { type: 'dir', size: 0 } : { type: 'file', size: v.length }
|
|
99
|
+
},
|
|
100
|
+
async read(p) {
|
|
101
|
+
return m.get(p)
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function tarReader(input) {
|
|
107
|
+
const blob = isBlob(input)
|
|
108
|
+
const u8 = input instanceof ArrayBuffer ? new Uint8Array(input) : input
|
|
109
|
+
const total = blob ? input.size : u8.length
|
|
110
|
+
const read = blob
|
|
111
|
+
? async (o, n) => new Uint8Array(await input.slice(o, o + n).arrayBuffer())
|
|
112
|
+
: (o, n) => u8.subarray(o, o + n)
|
|
113
|
+
return { blob, u8, total, read }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The bytes of one regular file of a tar (Uint8Array, ArrayBuffer or Blob), or null when the path is absent or
|
|
118
|
+
* not a regular file. Names are matched after normalisation, so './a' and 'a' are the same; pax and GNU long
|
|
119
|
+
* names are handled by scanTar. When a path appears twice the last entry wins, as when the tar is extracted.
|
|
120
|
+
*/
|
|
121
|
+
export async function readTarFile(tar, path) {
|
|
122
|
+
const want = normalizeContextPath(path)
|
|
123
|
+
const { read, total } = tarReader(tar)
|
|
124
|
+
let hit = null
|
|
125
|
+
for await (const e of scanTar(read, total)) {
|
|
126
|
+
let n = null
|
|
127
|
+
try {
|
|
128
|
+
n = normalizeContextPath(e.name)
|
|
129
|
+
} catch {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (n === want) hit = e.type === '0' || e.type === '7' ? e : null
|
|
133
|
+
}
|
|
134
|
+
if (!hit) return null
|
|
135
|
+
return hit.size ? new Uint8Array(await read(hit.offset, hit.size)) : new Uint8Array(0)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function packTar(input, dockerfile, lim) {
|
|
139
|
+
const { blob, u8, total, read } = tarReader(input)
|
|
140
|
+
let files = 0
|
|
141
|
+
let bytes = 0
|
|
142
|
+
const links = new Set()
|
|
143
|
+
// every folder an entry so far sits in: a symlink made later in one of them would sit over files already written
|
|
144
|
+
const parents = new Set()
|
|
145
|
+
try {
|
|
146
|
+
for await (const e of scanTar(read, total)) {
|
|
147
|
+
const name = normalizeContextPath(e.name) ?? ''
|
|
148
|
+
if (underLink(name, links)) throw new ContextError('unsafe-path', e.name, `build context: ${e.name} is under a symlink`)
|
|
149
|
+
for (let i = name.indexOf('/'); i > 0; i = name.indexOf('/', i + 1)) parents.add(name.slice(0, i))
|
|
150
|
+
if (e.type === '5') continue
|
|
151
|
+
if (e.type === '3' || e.type === '4' || e.type === '6') {
|
|
152
|
+
throw new ContextError('unsafe-path', e.name, `build context: device or fifo entry ${e.name}`)
|
|
153
|
+
}
|
|
154
|
+
if (e.type === '2') {
|
|
155
|
+
if (parents.has(name)) throw new ContextError('unsafe-path', e.name, `build context: symlink ${e.name} comes after entries inside it`)
|
|
156
|
+
links.add(name)
|
|
157
|
+
}
|
|
158
|
+
if (e.type === '1') {
|
|
159
|
+
// a target outside the context, or reached through a symlink, would make tar link a file outside it
|
|
160
|
+
let target = null
|
|
161
|
+
try {
|
|
162
|
+
target = normalizeContextPath(e.linkname)
|
|
163
|
+
} catch {}
|
|
164
|
+
if (!target || underLink(target, links)) {
|
|
165
|
+
throw new ContextError('unsafe-path', e.name, `build context: hardlink ${e.name} -> ${e.linkname} leaves the context`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (++files > lim.files) throw new ContextError('too-many', e.name)
|
|
169
|
+
const data = e.type === '0' || e.type === '7' ? e.size : 0
|
|
170
|
+
if (data > lim.fileBytes) throw new ContextError('file-too-big', e.name)
|
|
171
|
+
bytes += data
|
|
172
|
+
if (bytes > lim.bytes) throw new ContextError('too-big', e.name)
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err instanceof ContextError) throw err
|
|
176
|
+
throw new ContextError('bad-source', '', `build context: ${err.message}`)
|
|
177
|
+
}
|
|
178
|
+
const tar = blob ? new Uint8Array(await input.arrayBuffer()) : u8
|
|
179
|
+
return { tar, files, bytes, ignored: 0, dockerfile }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function mtimeOf(st) {
|
|
183
|
+
return st.mtime
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Packs a build context.
|
|
188
|
+
* @param source an async file source {list, stat, read}, a plain {[path]: Uint8Array|string}, or a tar
|
|
189
|
+
* (Uint8Array, ArrayBuffer or Blob), which is passed through after the limit checks.
|
|
190
|
+
* @param opts.dockerfile Dockerfile text given separately: the entry named 'Dockerfile' is then left out of
|
|
191
|
+
* the tar (the builder sends it on its own) and the text is returned as `dockerfile`.
|
|
192
|
+
* @param opts.dockerfileName the Dockerfile's path in the context (default 'Dockerfile'). Like BuildKit, a
|
|
193
|
+
* `<dockerfileName>.dockerignore` in the source is used instead of `.dockerignore` (both stay in the tar).
|
|
194
|
+
* @param opts.limits {bytes, files, fileBytes}, default 128 MiB, 20,000, 64 MiB.
|
|
195
|
+
* @returns {{tar: Uint8Array, files: number, bytes: number, ignored: number, dockerfile?: string}}
|
|
196
|
+
* files = regular files and symlinks in the tar; bytes = their content; ignored = paths .dockerignore removed.
|
|
197
|
+
*
|
|
198
|
+
* .dockerignore and the Dockerfile stay in the tar even when a pattern matches them: the docker CLI always sends
|
|
199
|
+
* both (builder/remotecontext), and BuildKit's frontend reads them from the context.
|
|
200
|
+
* File mtime is in seconds (or a Date); a number is not guessed to be milliseconds.
|
|
201
|
+
*/
|
|
202
|
+
export async function packContext(source, { dockerfile, dockerfileName = DOCKERFILE, limits } = {}) {
|
|
203
|
+
const lim = limitsOf(limits)
|
|
204
|
+
if (dockerfile !== undefined && typeof dockerfile !== 'string') {
|
|
205
|
+
throw new ContextError('bad-source', '', 'build context: dockerfile must be text')
|
|
206
|
+
}
|
|
207
|
+
if (source instanceof Uint8Array || source instanceof ArrayBuffer || isBlob(source)) {
|
|
208
|
+
return packTar(source, dockerfile, lim)
|
|
209
|
+
}
|
|
210
|
+
const src = isFileSource(source) ? source : source && typeof source === 'object' ? objectSource(source) : null
|
|
211
|
+
if (!src) throw new ContextError('bad-source', '', 'build context: source must be a file source, an object or a tar')
|
|
212
|
+
|
|
213
|
+
// 1. the paths, normalized and sorted (determinism: the tar does not depend on listing order)
|
|
214
|
+
const raw = await src.list()
|
|
215
|
+
const byNorm = new Map() // normalized path -> the path to give src.stat/read
|
|
216
|
+
const explicitDirs = new Set()
|
|
217
|
+
let rootRaw // how the source spells its own root ('.', './', ''), if it lists it
|
|
218
|
+
for (const p of raw) {
|
|
219
|
+
const n = normalizeContextPath(p)
|
|
220
|
+
if (n === null) {
|
|
221
|
+
rootRaw ??= p
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (!byNorm.has(n)) byNorm.set(n, p)
|
|
225
|
+
if (p.endsWith('/')) explicitDirs.add(n)
|
|
226
|
+
}
|
|
227
|
+
const sorted = [...byNorm.keys()].sort(byComponents)
|
|
228
|
+
|
|
229
|
+
// 2. .dockerignore, read before anything else
|
|
230
|
+
let ignoredFn = () => false
|
|
231
|
+
const specific = `${dockerfileName}${DOCKERIGNORE}`
|
|
232
|
+
const ignoreName = byNorm.has(specific) ? specific : DOCKERIGNORE
|
|
233
|
+
if (byNorm.has(ignoreName)) {
|
|
234
|
+
const text = decodeText(await src.read(byNorm.get(ignoreName)))
|
|
235
|
+
try {
|
|
236
|
+
ignoredFn = matcher(parseDockerignore(text)).ignored
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw new ContextError('bad-dockerignore', ignoreName, `build context: ${ignoreName}: ${e.message}`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const mustKeep = (p) => p === DOCKERIGNORE || p === specific || p === dockerfileName
|
|
242
|
+
const omit = (p) => dockerfile !== undefined && p === dockerfileName
|
|
243
|
+
|
|
244
|
+
// 3. filter, before stat/read
|
|
245
|
+
let ignored = 0
|
|
246
|
+
const kept = []
|
|
247
|
+
for (const p of sorted) {
|
|
248
|
+
if (omit(p)) continue
|
|
249
|
+
if (!mustKeep(p) && ignoredFn(p, explicitDirs.has(p))) {
|
|
250
|
+
ignored++
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
kept.push(p)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 4. stat and limit checks, all before any read
|
|
257
|
+
const entries = []
|
|
258
|
+
let files = 0
|
|
259
|
+
let bytes = 0
|
|
260
|
+
const statOf = async (p) => {
|
|
261
|
+
const st = await src.stat(byNorm.get(p))
|
|
262
|
+
if (!st || (st.type !== 'file' && st.type !== 'dir' && st.type !== 'symlink')) {
|
|
263
|
+
throw new ContextError('bad-source', p, `build context: bad stat for ${p}`)
|
|
264
|
+
}
|
|
265
|
+
return st
|
|
266
|
+
}
|
|
267
|
+
const stats = []
|
|
268
|
+
for (let i = 0; i < kept.length; i += 32) {
|
|
269
|
+
stats.push(...(await Promise.all(kept.slice(i, i + 32).map(statOf))))
|
|
270
|
+
}
|
|
271
|
+
const links = new Set(kept.filter((p, i) => stats[i].type === 'symlink'))
|
|
272
|
+
for (const p of kept) if (underLink(p, links)) throw new ContextError('unsafe-path', p, `build context: ${p} is under a symlink`)
|
|
273
|
+
kept.forEach((p, i) => {
|
|
274
|
+
const st = stats[i]
|
|
275
|
+
if (st.type === 'dir') {
|
|
276
|
+
entries.push({ path: p, st })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (++files > lim.files) throw new ContextError('too-many', p)
|
|
280
|
+
if (st.type === 'symlink') {
|
|
281
|
+
if (!st.target || st.target.includes('\0')) throw new ContextError('bad-source', p, `build context: bad symlink ${p}`)
|
|
282
|
+
|
|
283
|
+
} else if (st.size !== undefined) {
|
|
284
|
+
if (st.size > lim.fileBytes) throw new ContextError('file-too-big', p)
|
|
285
|
+
bytes += st.size
|
|
286
|
+
if (bytes > lim.bytes) throw new ContextError('too-big', p)
|
|
287
|
+
}
|
|
288
|
+
entries.push({ path: p, st })
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
// 5. write; parents of kept entries that were not kept (or not listed) become directory entries
|
|
292
|
+
const w = tarWriter()
|
|
293
|
+
// The root folder entry './' goes first, as `tar -c .` writes it: a COPY of the context's root gives its destination folder this
|
|
294
|
+
// time and mode, and the builder's tar extraction sets the context folder from it. Mode and mtime come from the source's own
|
|
295
|
+
// root when it lists one, else mode 0755 and the newest mtime of what is in the tar (none known: 0).
|
|
296
|
+
let rootSt = {}
|
|
297
|
+
if (rootRaw !== undefined) {
|
|
298
|
+
try {
|
|
299
|
+
rootSt = (await src.stat(rootRaw)) || {}
|
|
300
|
+
} catch {
|
|
301
|
+
rootSt = {}
|
|
302
|
+
}
|
|
303
|
+
if (rootSt.type && rootSt.type !== 'dir') rootSt = {}
|
|
304
|
+
}
|
|
305
|
+
const secs = (t) => (t instanceof Date ? Math.floor(t.getTime() / 1000) : Number(t) || 0)
|
|
306
|
+
let newest = 0
|
|
307
|
+
for (const { st } of entries) newest = Math.max(newest, secs(mtimeOf(st)))
|
|
308
|
+
w.dir('./', { mode: rootSt.mode !== undefined ? rootSt.mode & 0o7777 : 0o755, mtime: rootSt.mtime !== undefined ? rootSt.mtime : newest })
|
|
309
|
+
const made = new Set()
|
|
310
|
+
const keptDirs = new Set(entries.filter((e) => e.st.type === 'dir').map((e) => e.path))
|
|
311
|
+
const ensureDirs = async (p) => {
|
|
312
|
+
const parts = p.split('/')
|
|
313
|
+
for (let i = 1; i < parts.length; i++) {
|
|
314
|
+
const d = parts.slice(0, i).join('/')
|
|
315
|
+
if (made.has(d) || keptDirs.has(d)) continue
|
|
316
|
+
made.add(d)
|
|
317
|
+
let st = {}
|
|
318
|
+
if (byNorm.has(d)) {
|
|
319
|
+
try {
|
|
320
|
+
st = await statOf(d)
|
|
321
|
+
} catch {
|
|
322
|
+
st = {}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
w.dir(d, { mode: st.mode !== undefined ? st.mode & 0o7777 : 0o755, mtime: mtimeOf(st) })
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// entries are sorted, so a kept dir precedes its children; implicit parents are emitted just before first use
|
|
329
|
+
let written = 0
|
|
330
|
+
for (const { path, st } of entries) {
|
|
331
|
+
await ensureDirs(path)
|
|
332
|
+
if (st.type === 'dir') {
|
|
333
|
+
made.add(path)
|
|
334
|
+
w.dir(path, { mode: st.mode !== undefined ? st.mode & 0o7777 : 0o755, mtime: mtimeOf(st) })
|
|
335
|
+
} else if (st.type === 'symlink') {
|
|
336
|
+
w.symlink(path, st.target, { mtime: mtimeOf(st) })
|
|
337
|
+
} else {
|
|
338
|
+
const data = await src.read(byNorm.get(path))
|
|
339
|
+
if (!(data instanceof Uint8Array)) throw new ContextError('bad-source', path, `build context: read(${path}) is not bytes`)
|
|
340
|
+
if (data.length > lim.fileBytes) throw new ContextError('file-too-big', path)
|
|
341
|
+
if (st.size === undefined || st.size !== data.length) {
|
|
342
|
+
written += data.length - (st.size ?? 0)
|
|
343
|
+
if (bytes + written > lim.bytes) throw new ContextError('too-big', path)
|
|
344
|
+
}
|
|
345
|
+
w.file(path, data, { mode: st.mode !== undefined ? st.mode & 0o7777 : 0o644, mtime: mtimeOf(st) })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { tar: w.finish(), files, bytes: bytes + written, ignored, dockerfile }
|
|
349
|
+
}
|