@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.
@@ -0,0 +1,1055 @@
1
+ // A Dockerfile parser that follows BuildKit's dockerfile frontend (v0.33.0; third_party/buildkit/frontend/
2
+ // dockerfile/{parser,instructions,shell}): line continuation, comments, parser directives, heredoc detection,
3
+ // JSON and shell forms, builder flags, and the typed instructions of instructions.Parse. Variable expansion
4
+ // (shell.Lex.ProcessWord) is done for plain `$VAR` and `${VAR}` only; any other `${VAR<modifier>...}` throws
5
+ // FallbackError, so the caller sends the file to BuildKit.
6
+ //
7
+ // const df = parseDockerfile(text)
8
+ // df.stages[i].commands typed instructions (see parseInstruction)
9
+ // df.metaArgs ARG instructions before the first FROM
10
+ // df.directives {syntax, escape, check} parser directives found
11
+ // df.ast the raw parse tree, as parser.Parse makes it ([{value, flags, next, children, heredocs, ...}])
12
+ //
13
+ // What is not ported: linter rules (the page runs none), source locations beyond the first/last line, and the
14
+ // instruction forms the page never evaluates (a RUN is parsed, never run).
15
+
16
+ const WS = /[\t\v\f\r ]+/ // reWhitespace
17
+
18
+ /** A Dockerfile BuildKit would refuse (or this port cannot read); `line` is 1-based when known. */
19
+ export class DockerfileError extends Error {
20
+ constructor(message, line = 0) {
21
+ super(line ? `dockerfile parse error on line ${line}: ${message}` : message)
22
+ this.name = 'DockerfileError'
23
+ this.line = line
24
+ this.detail = message
25
+ }
26
+ }
27
+
28
+ /** The Dockerfile uses something the page does not evaluate: BuildKit must build it. */
29
+ export class FallbackError extends Error {
30
+ constructor(why) {
31
+ super(why)
32
+ this.name = 'FallbackError'
33
+ this.why = why
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------- text helpers (Go's unicode / strings)
38
+
39
+ const isSpaceCh = (c) => {
40
+ const n = c.codePointAt(0)
41
+ return (n >= 9 && n <= 13) || n === 32 || n === 0x85 || n === 0xa0 || n === 0x1680 || (n >= 0x2000 && n <= 0x200a) ||
42
+ n === 0x2028 || n === 0x2029 || n === 0x202f || n === 0x205f || n === 0x3000
43
+ }
44
+ const trimLeftSpace = (s) => {
45
+ let i = 0
46
+ while (i < s.length && isSpaceCh(s[i])) i++
47
+ return s.slice(i)
48
+ }
49
+ const trimSpace = (s) => {
50
+ let b = s.length
51
+ while (b > 0 && isSpaceCh(s[b - 1])) b--
52
+ return trimLeftSpace(s.slice(0, b))
53
+ }
54
+ const trimNewline = (s) => s.replace(/[\r\n]+$/, '')
55
+ const isComment = (s) => {
56
+ const t = trimLeftSpace(s)
57
+ return t.length > 0 && t[0] === '#'
58
+ }
59
+
60
+ /** strconv.Quote for the strings Dockerfile tests contain. */
61
+ export function goQuote(s) {
62
+ let out = '"'
63
+ for (const ch of s) {
64
+ const c = ch.codePointAt(0)
65
+ if (ch === '"') out += '\\"'
66
+ else if (ch === '\\') out += '\\\\'
67
+ else if (ch === '\n') out += '\\n'
68
+ else if (ch === '\t') out += '\\t'
69
+ else if (ch === '\r') out += '\\r'
70
+ else if (ch === '\x07') out += '\\a'
71
+ else if (ch === '\b') out += '\\b'
72
+ else if (ch === '\f') out += '\\f'
73
+ else if (ch === '\v') out += '\\v'
74
+ else if (c < 0x20 || c === 0x7f) out += `\\x${c.toString(16).padStart(2, '0')}`
75
+ else out += ch
76
+ }
77
+ return `${out}"`
78
+ }
79
+
80
+ // ---------------------------------------------------------------- parser directives (parser/directives.go)
81
+
82
+ const VALID_DIRECTIVES = new Set(['syntax', 'escape', 'check'])
83
+ const DIRECTIVE_RE = /^([a-zA-Z][a-zA-Z0-9]*)\s*=\s*(.+?)\s*$/
84
+
85
+ class DirectiveParser {
86
+ constructor() {
87
+ this.line = 0
88
+ this.seen = new Set()
89
+ this.done = false
90
+ }
91
+
92
+ /** @returns {{name, value}|null} */
93
+ parseLine(line) {
94
+ this.line++
95
+ if (this.done) return null
96
+ if (!line.startsWith('#')) {
97
+ this.done = true
98
+ return null
99
+ }
100
+ const m = trimLeftSpace(line.slice(1)).match(DIRECTIVE_RE)
101
+ if (!m) {
102
+ this.done = true
103
+ return null
104
+ }
105
+ const k = m[1].toLowerCase()
106
+ if (!VALID_DIRECTIVES.has(k)) {
107
+ this.done = true
108
+ return null
109
+ }
110
+ if (this.seen.has(k)) throw new Error(`only one ${k} parser directive can be used`)
111
+ this.seen.add(k)
112
+ return { name: k, value: m[2], line: this.line }
113
+ }
114
+ }
115
+
116
+ function continuationRegex(esc) {
117
+ const e = esc === '`' ? '`' : '\\\\'
118
+ const cls = esc === '`' ? '[^`]' : '[^\\\\]'
119
+ return new RegExp(`(${cls})${e}[ \\t]*$|^${e}[ \\t]*$`)
120
+ }
121
+
122
+ // ---------------------------------------------------------------- line parsers (parser/line_parsers.go)
123
+
124
+ function parseWords(rest, esc) {
125
+ const words = []
126
+ let phase = 0 // 0 spaces, 1 word, 2 quote
127
+ let quote = ''
128
+ let blankOK = false
129
+ let sb = ''
130
+ const chars = Array.from(rest)
131
+ for (let pos = 0; pos <= chars.length; pos++) {
132
+ const ch = chars[pos]
133
+ const atEnd = pos === chars.length
134
+ if (phase === 0) {
135
+ if (atEnd) break
136
+ if (isSpaceCh(ch)) continue
137
+ phase = 1
138
+ }
139
+ if ((phase === 1 || phase === 2) && atEnd) {
140
+ if (blankOK || sb.length > 0) words.push(sb)
141
+ break
142
+ }
143
+ if (phase === 1) {
144
+ if (isSpaceCh(ch)) {
145
+ phase = 0
146
+ if (blankOK || sb.length > 0) words.push(sb)
147
+ sb = ''
148
+ blankOK = false
149
+ continue
150
+ }
151
+ let c = ch
152
+ if (c === "'" || c === '"') {
153
+ quote = c
154
+ blankOK = true
155
+ phase = 2
156
+ }
157
+ if (c === esc) {
158
+ if (pos + 1 === chars.length) continue
159
+ sb += c
160
+ pos++
161
+ c = chars[pos]
162
+ }
163
+ sb += c
164
+ continue
165
+ }
166
+ if (phase === 2) {
167
+ let c = ch
168
+ if (c === quote) phase = 1
169
+ if (c === esc && quote !== "'") {
170
+ if (pos + 1 === chars.length) {
171
+ phase = 1
172
+ continue
173
+ }
174
+ pos++
175
+ sb += c
176
+ c = chars[pos]
177
+ }
178
+ sb += c
179
+ }
180
+ }
181
+ return words
182
+ }
183
+
184
+ /** regexp.Split(s, n) for a regexp that never matches the empty string. */
185
+ function splitN(s, re, n) {
186
+ const out = []
187
+ let last = 0
188
+ for (const m of s.matchAll(new RegExp(re.source, 'g'))) {
189
+ if (n > 0 && out.length === n - 1) break
190
+ out.push(s.slice(last, m.index))
191
+ last = m.index + m[0].length
192
+ }
193
+ out.push(s.slice(last))
194
+ return out
195
+ }
196
+
197
+ function parseNameVal(rest, key, esc) {
198
+ const words = parseWords(rest, esc)
199
+ if (words.length === 0) return null
200
+ if (!words[0].includes('=')) {
201
+ // old format: KEY name value
202
+ const parts = splitN(rest, WS, 2)
203
+ if (parts.length < 2) throw new Error(`${key} must have two arguments`)
204
+ return { next: [parts[0], parts[1], ''] }
205
+ }
206
+ const next = []
207
+ for (const word of words) {
208
+ if (!word.includes('=')) throw new Error(`Syntax error - can't find = in ${goQuote(word)}. Must be of the form: name=value`)
209
+ const i = word.indexOf('=')
210
+ next.push(word.slice(0, i), word.slice(i + 1), '=')
211
+ }
212
+ return { next }
213
+ }
214
+
215
+ function parseStringsWhitespaceDelimited(rest) {
216
+ if (rest === '') return { next: [] }
217
+ return { next: splitN(rest, WS, -1) }
218
+ }
219
+
220
+ function parseString(rest) {
221
+ if (rest === '') return { next: [] }
222
+ return { next: [rest] }
223
+ }
224
+
225
+ function parseJSON(rest) {
226
+ const r = trimLeftSpace(rest)
227
+ if (!r.startsWith('[')) return { notArray: true }
228
+ let v
229
+ try {
230
+ v = JSON.parse(r)
231
+ } catch {
232
+ return { notArray: true }
233
+ }
234
+ if (!Array.isArray(v)) return { notArray: true }
235
+ for (const s of v) if (typeof s !== 'string') throw new Error('when using JSON array syntax, arrays must be comprised of strings only')
236
+ return { next: v, json: true }
237
+ }
238
+
239
+ function parseMaybeJSON(rest) {
240
+ if (rest === '') return { next: [] }
241
+ const j = parseJSON(rest)
242
+ if (!j.notArray) return j
243
+ return { next: [rest] }
244
+ }
245
+
246
+ function parseMaybeJSONToList(rest) {
247
+ const j = parseJSON(rest)
248
+ if (!j.notArray) return j
249
+ return parseStringsWhitespaceDelimited(rest)
250
+ }
251
+
252
+ function parseNameOrNameVal(rest, esc) {
253
+ return { next: parseWords(rest, esc) }
254
+ }
255
+
256
+ function parseHealthConfig(rest) {
257
+ let sep = 0
258
+ while (sep < rest.length && !isSpaceCh(rest[sep])) sep++
259
+ let next = sep
260
+ while (next < rest.length && isSpaceCh(rest[next])) next++
261
+ if (sep === 0) return { next: [] }
262
+ const typ = rest.slice(0, sep)
263
+ const cmd = parseMaybeJSON(rest.slice(next))
264
+ return { next: [typ, ...cmd.next], json: !!cmd.json }
265
+ }
266
+
267
+ function dispatchFor(name) {
268
+ switch (name) {
269
+ case 'add': case 'copy': case 'volume': return parseMaybeJSONToList
270
+ case 'arg': return parseNameOrNameVal
271
+ case 'cmd': case 'entrypoint': case 'run': case 'shell': return parseMaybeJSON
272
+ case 'env': return (rest, esc) => parseNameVal(rest, 'ENV', esc)
273
+ case 'label': return (rest, esc) => parseNameVal(rest, 'LABEL', esc)
274
+ case 'expose': case 'from': return parseStringsWhitespaceDelimited
275
+ case 'healthcheck': return parseHealthConfig
276
+ case 'maintainer': case 'stopsignal': case 'user': case 'workdir': return parseString
277
+ case 'onbuild': return 'sub'
278
+ default: return null
279
+ }
280
+ }
281
+
282
+ // ---------------------------------------------------------------- builder flags (parser/split_command.go)
283
+
284
+ function extractBuilderFlags(line, esc) {
285
+ const chars = Array.from(line)
286
+ const words = []
287
+ let phase = 0
288
+ let sb = ''
289
+ let quote = ''
290
+ let blankOK = false
291
+ for (let pos = 0; pos <= chars.length; pos++) {
292
+ const atEnd = pos === chars.length
293
+ let ch = chars[pos]
294
+ if (phase === 0) {
295
+ if (atEnd) break
296
+ if (isSpaceCh(ch)) continue
297
+ if (ch !== '-' || pos + 1 === chars.length || chars[pos + 1] !== '-') return { rest: chars.slice(pos).join(''), flags: words }
298
+ phase = 1
299
+ }
300
+ if ((phase === 1 || phase === 2) && atEnd) {
301
+ if (sb !== '--' && (blankOK || sb.length > 0)) words.push(sb)
302
+ break
303
+ }
304
+ if (phase === 1) {
305
+ if (isSpaceCh(ch)) {
306
+ phase = 0
307
+ if (sb === '--') return { rest: chars.slice(pos).join(''), flags: words }
308
+ if (blankOK || sb.length > 0) words.push(sb)
309
+ sb = ''
310
+ blankOK = false
311
+ continue
312
+ }
313
+ if (ch === "'" || ch === '"') {
314
+ quote = ch
315
+ blankOK = true
316
+ phase = 2
317
+ continue
318
+ }
319
+ if (ch === esc) {
320
+ if (pos + 1 === chars.length) continue
321
+ pos++
322
+ ch = chars[pos]
323
+ }
324
+ sb += ch
325
+ continue
326
+ }
327
+ if (phase === 2) {
328
+ if (ch === quote) {
329
+ phase = 1
330
+ continue
331
+ }
332
+ if (ch === esc) {
333
+ if (pos + 1 === chars.length) {
334
+ phase = 1
335
+ continue
336
+ }
337
+ pos++
338
+ ch = chars[pos]
339
+ }
340
+ sb += ch
341
+ }
342
+ }
343
+ return { rest: '', flags: words }
344
+ }
345
+
346
+ function splitCommand(line, esc) {
347
+ const t = trimSpace(line)
348
+ const cmdline = splitN(t, WS, 2)
349
+ let args = ''
350
+ let flags = []
351
+ if (cmdline.length === 2) {
352
+ const r = extractBuilderFlags(cmdline[1], esc)
353
+ args = r.rest
354
+ flags = r.flags
355
+ }
356
+ return { cmd: cmdline[0], flags, args: trimSpace(args) }
357
+ }
358
+
359
+ function newNodeFromLine(line, esc) {
360
+ const { cmd, flags, args } = splitCommand(line, esc)
361
+ const fn = dispatchFor(cmd.toLowerCase())
362
+ let r
363
+ if (fn === 'sub') r = args === '' ? { next: [] } : { next: [], children: [newNodeFromLine(args, esc)] }
364
+ else if (fn === null) r = { next: [] }
365
+ else r = fn(args, esc)
366
+ return { value: cmd, original: line, flags, next: r?.next || [], json: !!r?.json, children: r?.children || [], heredocs: [] }
367
+ }
368
+
369
+ // ---------------------------------------------------------------- heredocs (parser.go)
370
+
371
+ const HEREDOC_RE = /^(\d*)<<(-?)\s*([^<]*)$/
372
+
373
+ /** shlex word split that keeps quotes and escapes raw (RawQuotes+RawEscapes), as heredocsFromLine uses it. */
374
+ function rawWords(line) {
375
+ const words = []
376
+ let cur = ''
377
+ let inWord = false
378
+ const chars = Array.from(line)
379
+ for (let i = 0; i < chars.length; i++) {
380
+ const ch = chars[i]
381
+ if (ch === '\\') {
382
+ cur += ch
383
+ inWord = true
384
+ if (i + 1 < chars.length) cur += chars[++i]
385
+ continue
386
+ }
387
+ if (ch === "'" || ch === '"') {
388
+ inWord = true
389
+ cur += ch
390
+ let j = i + 1
391
+ while (j < chars.length && chars[j] !== ch) {
392
+ if (ch === '"' && chars[j] === '\\' && j + 1 < chars.length) cur += chars[j++]
393
+ cur += chars[j++]
394
+ }
395
+ if (j < chars.length) cur += chars[j]
396
+ i = j
397
+ continue
398
+ }
399
+ if (isSpaceCh(ch)) {
400
+ if (inWord) words.push(cur)
401
+ cur = ''
402
+ inWord = false
403
+ continue
404
+ }
405
+ cur += ch
406
+ inWord = true
407
+ }
408
+ if (inWord) words.push(cur)
409
+ return words
410
+ }
411
+
412
+ function unquoteWord(s) {
413
+ let out = ''
414
+ const chars = Array.from(s)
415
+ for (let i = 0; i < chars.length; i++) {
416
+ const ch = chars[i]
417
+ if (ch === '\\') {
418
+ if (i + 1 < chars.length) out += chars[++i]
419
+ } else if (ch === "'") {
420
+ let j = i + 1
421
+ while (j < chars.length && chars[j] !== "'") out += chars[j++]
422
+ i = j
423
+ } else if (ch === '"') {
424
+ let j = i + 1
425
+ while (j < chars.length && chars[j] !== '"') {
426
+ if (chars[j] === '\\' && j + 1 < chars.length && '"$\\'.includes(chars[j + 1])) j++
427
+ out += chars[j++]
428
+ }
429
+ i = j
430
+ } else out += ch
431
+ }
432
+ return out
433
+ }
434
+
435
+ /** parser.ParseHeredoc: {name, fd, expand, chomp, content} or null. */
436
+ export function parseHeredocWord(src) {
437
+ const m = src.match(HEREDOC_RE)
438
+ if (!m || m[3].length === 0) return null
439
+ const rest = m[3]
440
+ const name = unquoteWord(rest)
441
+ const quotes = (s) => (s.match(/['"]/g) || []).length
442
+ const expand = quotes(name) === quotes(rest)
443
+ return { name, fd: m[1] ? Number(m[1]) : 0, expand, chomp: m[2] === '-', content: '' }
444
+ }
445
+
446
+ function heredocsFromLine(line) {
447
+ const docs = []
448
+ for (const w of rawWords(line)) {
449
+ const h = parseHeredocWord(w)
450
+ if (h) docs.push(h)
451
+ }
452
+ return docs
453
+ }
454
+
455
+ function canContainHeredoc(node) {
456
+ let n = node
457
+ if (n.value.toLowerCase() === 'onbuild' && n.children.length) n = n.children[0]
458
+ const v = n.value.toLowerCase()
459
+ if (v !== 'add' && v !== 'copy' && v !== 'run') return false
460
+ return !n.json
461
+ }
462
+
463
+ // ---------------------------------------------------------------- Parse (parser.go)
464
+
465
+ function splitLines(text) {
466
+ const out = []
467
+ let i = 0
468
+ while (i < text.length) {
469
+ const j = text.indexOf('\n', i)
470
+ if (j < 0) {
471
+ out.push(text.slice(i))
472
+ break
473
+ }
474
+ out.push(text.slice(i, j + 1))
475
+ i = j + 1
476
+ }
477
+ return out
478
+ }
479
+
480
+ /**
481
+ * parser.Parse. Returns {ast: [node], escape, directives, warnings}.
482
+ * @param {string} text
483
+ */
484
+ export function parseRaw(text) {
485
+ let esc = '\\'
486
+ let contRe = continuationRegex(esc)
487
+ const dp = new DirectiveParser()
488
+ const directives = {}
489
+ const warnings = []
490
+ const lines = splitLines(text)
491
+ const ast = []
492
+ let currentLine = 0
493
+ let idx = 0
494
+
495
+ const setEscape = (v) => {
496
+ if (v !== '`' && v !== '\\') throw new Error(`invalid escape token '${v}' does not match \` or \\`)
497
+ esc = v
498
+ contRe = continuationRegex(esc)
499
+ }
500
+ const processLine = (raw, stripLeft) => {
501
+ let token = trimNewline(raw)
502
+ if (stripLeft) token = trimLeftSpace(token)
503
+ const d = dp.parseLine(token)
504
+ if (d) {
505
+ directives[d.name] = d.value
506
+ if (d.name === 'escape') setEscape(d.value)
507
+ }
508
+ return isComment(token) ? '' : token
509
+ }
510
+ const trimCont = (s) => (contRe.test(s) ? { text: s.replace(contRe, '$1'), end: false } : { text: s, end: true })
511
+
512
+ while (idx < lines.length) {
513
+ let raw = lines[idx++]
514
+ if (currentLine === 0 && raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1)
515
+ let tok
516
+ try {
517
+ tok = processLine(raw, true)
518
+ } catch (e) {
519
+ throw new DockerfileError(e.message, currentLine)
520
+ }
521
+ currentLine++
522
+ const startLine = currentLine
523
+ const first = trimCont(tok)
524
+ let end = first.end
525
+ if (end && first.text.length === 0) continue
526
+ let buf = first.text
527
+ let emptyCont = false
528
+ while (!end && idx < lines.length) {
529
+ const next = lines[idx++]
530
+ let t
531
+ try {
532
+ t = processLine(next, false)
533
+ } catch (e) {
534
+ throw new DockerfileError(e.message, currentLine)
535
+ }
536
+ currentLine++
537
+ if (isComment(next)) continue
538
+ if (trimLeftSpace(trimNewline(t)).length === 0) {
539
+ emptyCont = true
540
+ continue
541
+ }
542
+ const c = trimCont(t)
543
+ end = c.end
544
+ buf += c.text
545
+ }
546
+ if (emptyCont) warnings.push(`Empty continuation line found in: ${buf}`)
547
+
548
+ let node
549
+ try {
550
+ node = newNodeFromLine(buf, esc)
551
+ } catch (e) {
552
+ throw new DockerfileError(e.message, startLine)
553
+ }
554
+ if (canContainHeredoc(node) && buf.includes('<<')) {
555
+ for (const h of heredocsFromLine(buf)) {
556
+ let terminated = false
557
+ let content = h.content
558
+ while (idx < lines.length) {
559
+ const l = lines[idx++]
560
+ currentLine++
561
+ let t = trimNewline(l)
562
+ if (h.chomp) t = t.replace(/^\t+/, '')
563
+ if (t === h.name) {
564
+ terminated = true
565
+ break
566
+ }
567
+ content += l
568
+ }
569
+ if (!terminated) throw new DockerfileError('unterminated heredoc', startLine)
570
+ h.content = content
571
+ node.heredocs.push(h)
572
+ }
573
+ }
574
+ node.startLine = startLine
575
+ node.endLine = currentLine
576
+ ast.push(node)
577
+ }
578
+ if (ast.length === 0) throw new DockerfileError('file with no instructions', currentLine)
579
+ return { ast, escape: esc, directives, warnings }
580
+ }
581
+
582
+ /** The tree in the form parser/dumper uses and the testfiles' `result` files hold: "(cmd flags args)\n" per instruction. */
583
+ export function dump(ast) {
584
+ const node = (n) => {
585
+ let s = n.value.toLowerCase()
586
+ if (n.flags.length) s += ` [${n.flags.map(goQuote).join(' ')}]`
587
+ for (const a of n.next) s += ` ${goQuote(a)}`
588
+ for (const c of n.children) s += ` (${node(c)})`
589
+ return s.trim()
590
+ }
591
+ return ast.map((n) => `(${node(n)})\n`).join('')
592
+ }
593
+
594
+ // ---------------------------------------------------------------- flags (instructions/bflag.go)
595
+
596
+ /** @param {string[]} args flag words like '--chown=1:1'; spec: {name: 'bool'|'string'|'strings'} */
597
+ function parseFlags(args, spec) {
598
+ const out = {}
599
+ for (const a of args) {
600
+ if (a === '--') break
601
+ if (!a.startsWith('--')) throw new Error(`arg should start with -- : ${a}`)
602
+ const eq = a.indexOf('=')
603
+ const flagName = eq < 0 ? a : a.slice(0, eq)
604
+ const hasValue = eq >= 0
605
+ const value = hasValue ? a.slice(eq + 1) : ''
606
+ const name = flagName.slice(2)
607
+ const type = spec[name]
608
+ if (!type) throw new Error(`unknown flag: ${flagName}`)
609
+ if (name in out && type !== 'strings') throw new Error(`duplicate flag specified: ${flagName}`)
610
+ if (type === 'bool') {
611
+ if (hasValue && value === '') throw new Error(`missing a value on flag: ${flagName}`)
612
+ const v = value.toLowerCase()
613
+ if (v === 'true' || v === '') out[name] = true
614
+ else if (v === 'false') out[name] = false
615
+ else throw new Error(`expecting boolean value for flag ${flagName}, not: ${value}`)
616
+ } else if (type === 'string') {
617
+ if (!hasValue) throw new Error(`missing a value on flag: ${flagName}`)
618
+ out[name] = value
619
+ } else {
620
+ if (!hasValue) throw new Error(`missing a value on flag: ${flagName}`)
621
+ ;(out[name] ||= []).push(value)
622
+ }
623
+ }
624
+ return out
625
+ }
626
+
627
+ const NO_FLAGS = {}
628
+ const FLAG_SPECS = {
629
+ copy: { chown: 'string', from: 'string', chmod: 'string', link: 'bool', exclude: 'strings', parents: 'bool' },
630
+ add: { chown: 'string', chmod: 'string', link: 'bool', 'keep-git-dir': 'bool', checksum: 'string', unpack: 'bool', exclude: 'strings' },
631
+ from: { platform: 'string' },
632
+ healthcheck: { interval: 'string', timeout: 'string', 'start-period': 'string', 'start-interval': 'string', retries: 'string' },
633
+ // RUN has mount, network, security, device: the flags are kept as given, never evaluated
634
+ run: { mount: 'strings', network: 'string', security: 'string', device: 'strings' },
635
+ }
636
+
637
+ // ---------------------------------------------------------------- Go durations (time.ParseDuration)
638
+
639
+ const UNITS = { ns: 1, us: 1e3, 'µs': 1e3, 'μs': 1e3, ms: 1e6, s: 1e9, m: 6e10, h: 3.6e12 }
640
+ /** @returns {number} nanoseconds */
641
+ export function parseGoDuration(str) {
642
+ let s = str
643
+ let neg = false
644
+ if (s[0] === '-' || s[0] === '+') {
645
+ neg = s[0] === '-'
646
+ s = s.slice(1)
647
+ }
648
+ if (s === '0') return 0
649
+ if (s === '') throw new Error(`time: invalid duration ${goQuote(str)}`)
650
+ let total = 0
651
+ while (s !== '') {
652
+ const m = s.match(/^(\d*\.?\d*)([^\d.]*)/)
653
+ if (!m || m[1] === '' || m[1] === '.') throw new Error(`time: invalid duration ${goQuote(str)}`)
654
+ if (m[2] === '') throw new Error(`time: missing unit in duration ${goQuote(str)}`)
655
+ if (!(m[2] in UNITS)) throw new Error(`time: unknown unit ${goQuote(m[2])} in duration ${goQuote(str)}`)
656
+ total += Number(m[1]) * UNITS[m[2]]
657
+ s = s.slice(m[0].length)
658
+ }
659
+ return Math.round(neg ? -total : total)
660
+ }
661
+
662
+ const trimZeros = (x) => String(Number(x.toFixed(9)))
663
+ /** Go's time.Duration String(): "30s", "1m30s", "0s". */
664
+ export function goDurationString(ns) {
665
+ if (ns === 0) return '0s'
666
+ const neg = ns < 0
667
+ let u = Math.abs(ns)
668
+ let out
669
+ if (u < 1e9) {
670
+ if (u < 1e3) out = `${u}ns`
671
+ else if (u < 1e6) out = `${trimZeros(u / 1e3)}µs`
672
+ else out = `${trimZeros(u / 1e6)}ms`
673
+ } else {
674
+ const h = Math.floor(u / 3.6e12)
675
+ u -= h * 3.6e12
676
+ const m = Math.floor(u / 6e10)
677
+ u -= m * 6e10
678
+ out = `${h ? `${h}h` : ''}${h || m ? `${m}m` : ''}${trimZeros(u / 1e9)}s`
679
+ }
680
+ return neg ? `-${out}` : out
681
+ }
682
+
683
+ // ---------------------------------------------------------------- typed instructions (instructions/parse.go)
684
+
685
+ const handleJSONArgs = (args, json) => (args.length === 0 ? [] : json ? args : [args.join(' ')])
686
+
687
+ function parseKvps(next, cmd) {
688
+ if (next.length === 0) throw new Error(`${cmd} requires at least one argument`)
689
+ if (next.length % 3 !== 0) throw new Error(`Bad input to ${cmd}, too many arguments`)
690
+ const out = []
691
+ for (let i = 0; i < next.length; i += 3) {
692
+ if (next[i].length === 0) throw new Error(`${cmd} names can not be blank`)
693
+ out.push({ key: next[i], value: next[i + 1], noDelim: next[i + 2] === '' })
694
+ }
695
+ return out
696
+ }
697
+
698
+ function parseSourcesAndDest(args, heredocs, cmd) {
699
+ const srcs = args.slice(0, -1)
700
+ const dest = args[args.length - 1]
701
+ if (parseHeredocWord(dest)) throw new Error(`${cmd} cannot accept a heredoc as a destination`)
702
+ const lookup = new Map(heredocs.map((h) => [h.name, h]))
703
+ const sources = []
704
+ const contents = []
705
+ for (const s of srcs) {
706
+ const h = parseHeredocWord(s)
707
+ if (h) {
708
+ let data = lookup.get(h.name)?.content ?? ''
709
+ if (h.chomp) data = data.replace(/^\t+/gm, '')
710
+ contents.push({ path: h.name, data, expand: h.expand })
711
+ } else sources.push(s)
712
+ }
713
+ return { dest, sources, contents }
714
+ }
715
+
716
+ /**
717
+ * instructions.ParseInstruction: a typed object per instruction ({type, ...fields, line}).
718
+ * @param {object} node a raw node from parseRaw
719
+ */
720
+ export function parseInstruction(node) {
721
+ const name = node.value.toLowerCase()
722
+ const args = node.next
723
+ const base = { type: name, line: node.startLine, original: trimSpace(node.original) }
724
+ const flags = (spec) => parseFlags(node.flags, spec || NO_FLAGS)
725
+ switch (name) {
726
+ case 'env':
727
+ flags()
728
+ return { ...base, pairs: parseKvps(args, 'ENV') }
729
+ case 'label':
730
+ flags()
731
+ return { ...base, pairs: parseKvps(args, 'LABEL') }
732
+ case 'maintainer':
733
+ if (args.length !== 1) throw new Error('MAINTAINER requires exactly one argument')
734
+ flags()
735
+ return { ...base, maintainer: args[0] }
736
+ case 'add':
737
+ case 'copy': {
738
+ if (args.length < 2) throw new Error(`${name.toUpperCase()} requires at least two arguments, but only one was provided. Destination could not be determined`)
739
+ const f = flags(FLAG_SPECS[name])
740
+ const sd = parseSourcesAndDest(args, node.heredocs, name.toUpperCase())
741
+ return {
742
+ ...base,
743
+ sources: sd.sources,
744
+ dest: sd.dest,
745
+ contents: sd.contents,
746
+ from: f.from || '',
747
+ chown: f.chown || '',
748
+ chmod: f.chmod || '',
749
+ link: f.link === true,
750
+ parents: f.parents === true,
751
+ exclude: f.exclude || [],
752
+ flagsUsed: Object.keys(f),
753
+ }
754
+ }
755
+ case 'from': {
756
+ let stageName = ''
757
+ if (args.length === 3 && args[1].toLowerCase() === 'as') {
758
+ stageName = args[2].toLowerCase()
759
+ if (!/^[a-z][a-z0-9\-_.]*$/.test(stageName)) throw new Error(`invalid name for build stage: ${goQuote(args[2])}, name can't start with a number or contain symbols`)
760
+ } else if (args.length !== 1) throw new Error('FROM requires either one or three arguments')
761
+ const f = flags(FLAG_SPECS.from)
762
+ return { ...base, baseName: args[0], name: stageName, platform: f.platform || '', commands: [] }
763
+ }
764
+ case 'onbuild': {
765
+ if (!node.children.length) throw new Error('ONBUILD requires at least one argument')
766
+ flags()
767
+ const trigger = node.children[0].value.trim().toUpperCase()
768
+ if (trigger === 'ONBUILD') throw new Error("Chaining ONBUILD via `ONBUILD ONBUILD` isn't allowed")
769
+ if (trigger === 'MAINTAINER' || trigger === 'FROM') throw new Error(`${trigger} isn't allowed as an ONBUILD trigger`)
770
+ return { ...base, expression: node.original.replace(/^\s*ONBUILD\s*/i, '') }
771
+ }
772
+ case 'workdir':
773
+ if (args.length !== 1) throw new Error('WORKDIR requires exactly one argument')
774
+ flags()
775
+ return { ...base, path: args[0] }
776
+ case 'run': {
777
+ const f = flags(FLAG_SPECS.run)
778
+ return { ...base, flagsUsed: Object.keys(f), cmdline: handleJSONArgs(args, node.json), shell: !node.json, heredocs: node.heredocs.length }
779
+ }
780
+ case 'cmd':
781
+ case 'entrypoint':
782
+ flags()
783
+ return { ...base, cmdline: handleJSONArgs(args, node.json), shell: !node.json }
784
+ case 'healthcheck': {
785
+ if (args.length === 0) throw new Error('HEALTHCHECK requires at least one argument')
786
+ const typ = args[0].toUpperCase()
787
+ const rest = args.slice(1)
788
+ if (typ === 'NONE') {
789
+ if (rest.length !== 0) throw new Error('HEALTHCHECK NONE takes no arguments')
790
+ return { ...base, health: { Test: ['NONE'] } }
791
+ }
792
+ const f = flags(FLAG_SPECS.healthcheck)
793
+ if (typ !== 'CMD') throw new Error(`Unknown type ${goQuote(typ)} in HEALTHCHECK (try CMD)`)
794
+ const cmdSlice = handleJSONArgs(rest, node.json)
795
+ if (cmdSlice.length === 0) throw new Error('Missing command after HEALTHCHECK CMD')
796
+ const health = { Test: [node.json ? 'CMD' : 'CMD-SHELL', ...cmdSlice] }
797
+ const opt = (key, field) => {
798
+ if (!f[key]) return
799
+ const d = parseGoDuration(f[key])
800
+ if (d !== 0 && d < 1e6) throw new Error(`Interval ${goQuote(key)} cannot be less than 1ms`)
801
+ if (d) health[field] = d
802
+ }
803
+ opt('interval', 'Interval')
804
+ opt('timeout', 'Timeout')
805
+ opt('start-period', 'StartPeriod')
806
+ opt('start-interval', 'StartInterval')
807
+ if (f.retries !== undefined && f.retries !== '') {
808
+ if (!/^[+-]?\d+$/.test(f.retries) || Math.abs(Number(f.retries)) > 2 ** 31 - 1) throw new Error(`strconv.ParseInt: parsing ${goQuote(f.retries)}: invalid syntax`)
809
+ const r = Number(f.retries)
810
+ if (r < 0) throw new Error(`--retries cannot be negative (${r})`)
811
+ if (r) health.Retries = r
812
+ }
813
+ return { ...base, health }
814
+ }
815
+ case 'expose':
816
+ if (args.length === 0) throw new Error('EXPOSE requires at least one argument')
817
+ flags()
818
+ return { ...base, ports: [...args].sort() }
819
+ case 'user':
820
+ if (args.length !== 1) throw new Error('USER requires exactly one argument')
821
+ flags()
822
+ return { ...base, user: args[0] }
823
+ case 'volume': {
824
+ if (args.length === 0) throw new Error('VOLUME requires at least one argument')
825
+ flags()
826
+ const volumes = args.map((v) => v.trim())
827
+ if (volumes.some((v) => v === '')) throw new Error('VOLUME specified can not be an empty string')
828
+ return { ...base, volumes }
829
+ }
830
+ case 'stopsignal':
831
+ if (args.length !== 1) throw new Error('STOPSIGNAL requires exactly one argument')
832
+ flags()
833
+ return { ...base, signal: args[0] }
834
+ case 'arg':
835
+ if (args.length < 1) throw new Error('ARG requires at least one argument')
836
+ flags()
837
+ return {
838
+ ...base,
839
+ args: args.map((a) => {
840
+ const i = a.indexOf('=')
841
+ if (i < 0) return { key: a, value: null }
842
+ if (i === 0) throw new Error('ARG names can not be blank')
843
+ return { key: a.slice(0, i), value: a.slice(i + 1) }
844
+ }),
845
+ }
846
+ case 'shell': {
847
+ flags()
848
+ const shell = handleJSONArgs(args, node.json)
849
+ if (shell.length === 0) throw new Error('SHELL requires at least one argument')
850
+ if (!node.json) throw new Error(`Source: ${node.original} is not valid for SHELL: not JSON`)
851
+ return { ...base, shell }
852
+ }
853
+ default:
854
+ throw new Error(`unknown instruction: ${node.value}`)
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Parses a Dockerfile into stages (instructions.Parse): {stages: [{name, baseName, platform, commands, line}], metaArgs,
860
+ * directives, escape, ast, warnings}. Throws DockerfileError.
861
+ */
862
+ export function parseDockerfile(text) {
863
+ const raw = parseRaw(String(text))
864
+ const stages = []
865
+ const metaArgs = []
866
+ for (const node of raw.ast) {
867
+ let cmd
868
+ try {
869
+ cmd = parseInstruction(node)
870
+ } catch (e) {
871
+ if (e instanceof DockerfileError) throw e
872
+ throw new DockerfileError(e.message, node.startLine)
873
+ }
874
+ if (stages.length === 0 && cmd.type === 'arg') {
875
+ metaArgs.push(cmd)
876
+ continue
877
+ }
878
+ if (cmd.type === 'from') stages.push(cmd)
879
+ else {
880
+ if (stages.length === 0) throw new DockerfileError('no build stage in current context', node.startLine)
881
+ stages[stages.length - 1].commands.push(cmd)
882
+ }
883
+ }
884
+ return { stages, metaArgs, directives: raw.directives, escape: raw.escape, ast: raw.ast, warnings: raw.warnings }
885
+ }
886
+
887
+ // ---------------------------------------------------------------- variable expansion (shell/lex.go ProcessWord)
888
+
889
+ /** An env as BuildKit's shell.EnvGetter: insertion ordered, set replaces in place. */
890
+ export class Env {
891
+ constructor(pairs = []) {
892
+ this.map = new Map(pairs)
893
+ }
894
+
895
+ get(k) {
896
+ return this.map.get(k)
897
+ }
898
+
899
+ has(k) {
900
+ return this.map.has(k)
901
+ }
902
+
903
+ set(k, v) {
904
+ this.map.set(k, v)
905
+ }
906
+
907
+ keys() {
908
+ return [...this.map.keys()]
909
+ }
910
+
911
+ clone() {
912
+ return new Env([...this.map])
913
+ }
914
+ }
915
+
916
+ const SPECIAL_PARAMS = '@*#?-$!0'
917
+ const isLetter = (c) => /\p{L}/u.test(c)
918
+ const isDigit = (c) => /\p{Nd}/u.test(c)
919
+
920
+ /**
921
+ * shell.Lex.ProcessWord for plain `$VAR` and `${VAR}`: quotes and escapes are processed as BuildKit does, an unset
922
+ * variable is the empty string. A modifier (`${X:-y}`, `${X#p}`, ...) and special parameters (`$@`, `$$`, `$0`) throw
923
+ * FallbackError; an unterminated quote or brace throws DockerfileError (BuildKit refuses it too).
924
+ * @param {string} word
925
+ * @param {{get(k: string): string|undefined}} env
926
+ * @param {{escape?: string, words?: string[]}} [o] words: also split the result into words as ProcessWords does
927
+ * @param {Set<string>} [used] receives the names looked up
928
+ */
929
+ export function expandWord(word, env, { escape = '\\', words } = {}, used) {
930
+ const chars = Array.from(word)
931
+ let i = 0
932
+ const peek = () => chars[i]
933
+ const next = () => chars[i++]
934
+ const shown = JSON.stringify(word)
935
+
936
+ const name = () => {
937
+ let n = ''
938
+ while (i < chars.length) {
939
+ const c = peek()
940
+ if (n.length === 0 && isDigit(c)) {
941
+ while (i < chars.length && isDigit(peek())) n += next()
942
+ return n
943
+ }
944
+ if (n.length === 0 && SPECIAL_PARAMS.includes(c)) return next()
945
+ if (!isLetter(c) && !isDigit(c) && c !== '_') break
946
+ n += next()
947
+ }
948
+ return n
949
+ }
950
+ const lookup = (n) => {
951
+ if (n.length === 1 && SPECIAL_PARAMS.includes(n)) throw new FallbackError(`the special parameter $${n} in ${shown}`)
952
+ if (/^\d+$/.test(n)) throw new FallbackError(`the positional parameter $${n} in ${shown}`)
953
+ used?.add(n)
954
+ const v = env.get(n)
955
+ return v === undefined ? '' : v
956
+ }
957
+ const dollar = () => {
958
+ next() // $
959
+ if (peek() !== '{') {
960
+ const n = name()
961
+ return n === '' ? '$' : lookup(n)
962
+ }
963
+ next()
964
+ if (i >= chars.length) throw new DockerfileError(`failed to process ${shown}: syntax error: missing '}'`)
965
+ const c = peek()
966
+ if (c === '{' || c === '}' || c === ':') throw new DockerfileError(`failed to process ${shown}: syntax error: bad substitution`)
967
+ const n = name()
968
+ const ch = next()
969
+ if (ch === '}') return lookup(n)
970
+ if (ch === undefined) throw new DockerfileError(`failed to process ${shown}: syntax error: missing '}'`)
971
+ throw new FallbackError(`the substitution \${${n}${ch}...} in ${shown}`)
972
+ }
973
+ const single = () => {
974
+ next()
975
+ let r = ''
976
+ for (;;) {
977
+ const c = next()
978
+ if (c === undefined) throw new DockerfileError(`failed to process ${shown}: unexpected end of statement while looking for matching single-quote`)
979
+ if (c === "'") return r
980
+ r += c
981
+ }
982
+ }
983
+ const double = () => {
984
+ next()
985
+ let r = ''
986
+ for (;;) {
987
+ const c = peek()
988
+ if (c === undefined) throw new DockerfileError(`failed to process ${shown}: unexpected end of statement while looking for matching double-quote`)
989
+ if (c === '"') {
990
+ next()
991
+ return r
992
+ }
993
+ if (c === '$') {
994
+ r += dollar()
995
+ continue
996
+ }
997
+ let ch = next()
998
+ if (ch === escape) {
999
+ const p = peek()
1000
+ if (p === undefined) continue
1001
+ if (p === '"' || p === '$' || p === escape) ch = next()
1002
+ }
1003
+ r += ch
1004
+ }
1005
+ }
1006
+ let out = ''
1007
+ // ProcessWords' splitting: a variable's value splits at spaces, a quoted part and an escaped char do not
1008
+ let cur = ''
1009
+ let inWord = false
1010
+ const addRaw = (s) => {
1011
+ cur += s
1012
+ if (s !== '') inWord = true
1013
+ }
1014
+ const addVar = (s) => {
1015
+ for (const ch of s) {
1016
+ if (isSpaceCh(ch)) {
1017
+ if (inWord && cur !== '') {
1018
+ words?.push(cur)
1019
+ cur = ''
1020
+ inWord = false
1021
+ }
1022
+ } else addRaw(ch)
1023
+ }
1024
+ }
1025
+ while (i < chars.length) {
1026
+ const c = peek()
1027
+ if (c === '$') {
1028
+ const v = dollar()
1029
+ out += v
1030
+ addVar(v)
1031
+ } else if (c === "'" || c === '"') {
1032
+ const v = c === "'" ? single() : double()
1033
+ out += v
1034
+ addRaw(v)
1035
+ } else if (c === '<') {
1036
+ next()
1037
+ if (peek() === '<') throw new FallbackError(`a heredoc marker in ${shown}`)
1038
+ out += '<'
1039
+ addRaw('<')
1040
+ } else {
1041
+ let ch = next()
1042
+ if (ch === escape) {
1043
+ ch = next()
1044
+ if (ch === undefined) break
1045
+ addRaw(ch)
1046
+ } else addVar(ch)
1047
+ out += ch
1048
+ }
1049
+ }
1050
+ if (cur !== '') words?.push(cur)
1051
+ return out
1052
+ }
1053
+
1054
+ /** Go's %q of a []string: ["a" "b"] (what BuildKit writes in a history line for CMD and ENTRYPOINT). */
1055
+ export const goQuoteList = (a) => `[${a.map(goQuote).join(' ')}]`