@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,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(' ')}]`
|