@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,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
+ }