Skip to content

Commit ce3037d

Browse files
committed
implement dockerfile parser
1 parent 1f96b33 commit ce3037d

10 files changed

Lines changed: 1697 additions & 177 deletions
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""Conservative Dockerfile analysis for remote build context selection.
2+
3+
This module intentionally does not try to execute or fully reproduce the
4+
BuildKit Dockerfile frontend. It recognizes the context-consuming syntax the
5+
SDK can analyze safely and requests a full context for anything ambiguous.
6+
That keeps sparse uploads an optimization rather than a correctness
7+
requirement.
8+
"""
9+
10+
import json
11+
import re
12+
import shlex
13+
from typing import Dict, List, Optional, Tuple
14+
from urllib.parse import urlsplit
15+
16+
17+
_KNOWN_INSTRUCTIONS = frozenset(
18+
{
19+
"ADD",
20+
"ARG",
21+
"CMD",
22+
"COPY",
23+
"ENTRYPOINT",
24+
"ENV",
25+
"EXPOSE",
26+
"FROM",
27+
"HEALTHCHECK",
28+
"LABEL",
29+
"MAINTAINER",
30+
"ONBUILD",
31+
"RUN",
32+
"SHELL",
33+
"STOPSIGNAL",
34+
"USER",
35+
"VOLUME",
36+
"WORKDIR",
37+
}
38+
)
39+
_KNOWN_RUN_FLAGS = frozenset({"device", "mount", "network", "security"})
40+
_INSTRUCTION_PATTERN = re.compile(r"^([A-Za-z]+)(?:[ \t]+(.*))?$")
41+
_DIRECTIVE_PATTERN = re.compile(
42+
r"(?im)^\s*#\s*(?P<name>escape|syntax)\s*=\s*(?P<value>\S+)"
43+
)
44+
_FLAG_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9-]*$")
45+
_SCP_GIT_SOURCE_PATTERN = re.compile(r"^git@[^:/\s]+:.+")
46+
47+
48+
class _AnalysisFallback(Exception):
49+
def __init__(self, reason: str):
50+
super().__init__(reason)
51+
self.reason = reason
52+
53+
54+
class _DockerfileSourceAnalyzer:
55+
def __init__(self, data: bytes):
56+
self._data = data
57+
58+
def analyze(self) -> Tuple[List[List[str]], str]:
59+
try:
60+
text = self._data.decode("utf-8")
61+
except UnicodeDecodeError:
62+
return [], "dockerfile_parse_failed"
63+
64+
try:
65+
self._validate_parser_directives(text)
66+
# Heredocs are valid BuildKit syntax, but treating the entire
67+
# Dockerfile as full context is safer than partially parsing them.
68+
if "<<" in text:
69+
raise _AnalysisFallback("dockerfile_instruction_parse_failed")
70+
logical_lines = self._logical_lines(text)
71+
return self._analyze_lines(logical_lines), ""
72+
except _AnalysisFallback as exc:
73+
return [], exc.reason
74+
75+
@staticmethod
76+
def _validate_parser_directives(text: str) -> None:
77+
for match in _DIRECTIVE_PATTERN.finditer(text):
78+
name = match.group("name").lower()
79+
value = match.group("value")
80+
if name == "escape" and value != "\\":
81+
raise _AnalysisFallback("dockerfile_parse_failed")
82+
if name == "syntax" and not _is_official_dockerfile_frontend(value):
83+
raise _AnalysisFallback("custom_dockerfile_frontend")
84+
85+
@staticmethod
86+
def _logical_lines(text: str) -> List[str]:
87+
logical_lines = []
88+
parts: List[str] = []
89+
for raw_line in text.splitlines():
90+
stripped = raw_line.strip()
91+
if not parts and (not stripped or stripped.startswith("#")):
92+
continue
93+
if parts and (not stripped or stripped.startswith("#")):
94+
# BuildKit has nuanced rules for comments and empty lines in a
95+
# continuation. Full context is the safe answer here.
96+
raise _AnalysisFallback("dockerfile_parse_failed")
97+
if stripped.endswith("\\"):
98+
parts.append(stripped[:-1].rstrip())
99+
continue
100+
parts.append(stripped)
101+
logical_lines.append(" ".join(parts))
102+
parts = []
103+
if parts:
104+
raise _AnalysisFallback("dockerfile_parse_failed")
105+
return logical_lines
106+
107+
def _analyze_lines(self, logical_lines: List[str]) -> List[List[str]]:
108+
groups = []
109+
for line in logical_lines:
110+
instruction, body = _parse_instruction(line)
111+
if instruction not in _KNOWN_INSTRUCTIONS:
112+
raise _AnalysisFallback("dockerfile_instruction_parse_failed")
113+
if instruction in ("COPY", "ADD"):
114+
group = self._analyze_copy_or_add(instruction, body)
115+
if group:
116+
groups.append(group)
117+
elif instruction == "RUN":
118+
groups.extend(self._analyze_run(body))
119+
return groups
120+
121+
@staticmethod
122+
def _analyze_copy_or_add(instruction: str, body: str) -> List[str]:
123+
try:
124+
flags, values = _parse_copy_add_values(body)
125+
except (ValueError, json.JSONDecodeError):
126+
raise _AnalysisFallback("dockerfile_instruction_parse_failed")
127+
128+
if instruction == "COPY" and "from" in flags:
129+
return []
130+
131+
local_sources = []
132+
for source in values[:-1]:
133+
if "$" in source or "\x00" in source:
134+
reason = (
135+
"copy_source_requires_expansion"
136+
if instruction == "COPY"
137+
else "add_source_requires_expansion"
138+
)
139+
raise _AnalysisFallback(reason)
140+
if "\\" in source:
141+
# A backslash is a path character on Unix but a separator on
142+
# Windows. Full context keeps selection platform-independent.
143+
raise _AnalysisFallback("dockerfile_source_path")
144+
if instruction == "ADD" and _is_remote_add_source(source):
145+
continue
146+
if _source_has_pattern(source):
147+
# Python glob semantics differ from Go filepath.Match for
148+
# dotfiles, character classes, and recursive patterns.
149+
raise _AnalysisFallback("dockerfile_source_pattern")
150+
local_sources.append(source)
151+
return local_sources
152+
153+
@staticmethod
154+
def _analyze_run(body: str) -> List[List[str]]:
155+
try:
156+
flags, _ = _split_leading_flags(body)
157+
except ValueError:
158+
raise _AnalysisFallback("run_mount_parse_failed")
159+
160+
groups = []
161+
for name, value in flags:
162+
if name not in _KNOWN_RUN_FLAGS:
163+
raise _AnalysisFallback("dockerfile_instruction_parse_failed")
164+
if name != "mount":
165+
continue
166+
if not value or any(character in value for character in ('"', "'")):
167+
raise _AnalysisFallback("run_mount_parse_failed")
168+
options = _parse_mount_options(value)
169+
if options.get("type", "bind") != "bind" or options.get("from"):
170+
continue
171+
source = options.get("source") or options.get("src") or "."
172+
if "$" in source or "\x00" in source:
173+
raise _AnalysisFallback("run_bind_source_requires_expansion")
174+
groups.append([source])
175+
return groups
176+
177+
178+
def analyze_dockerfile_sources(data: bytes) -> Tuple[List[List[str]], str]:
179+
"""Return local source groups or a reason to upload the full context."""
180+
181+
return _DockerfileSourceAnalyzer(data).analyze()
182+
183+
184+
def _parse_instruction(line: str) -> Tuple[str, str]:
185+
match = _INSTRUCTION_PATTERN.fullmatch(line)
186+
if match is None:
187+
raise _AnalysisFallback("dockerfile_parse_failed")
188+
return match.group(1).upper(), match.group(2) or ""
189+
190+
191+
def _parse_copy_add_values(body: str) -> Tuple[Dict[str, str], List[str]]:
192+
parsed_flags, remaining = _split_leading_flags(body)
193+
flags = dict(parsed_flags)
194+
if remaining.startswith("["):
195+
values = json.loads(remaining)
196+
if not isinstance(values, list) or not all(
197+
isinstance(value, str) for value in values
198+
):
199+
raise ValueError("invalid JSON COPY/ADD")
200+
else:
201+
if "\\" in remaining:
202+
# BuildKit's shell-form COPY/ADD word splitting is not POSIX shlex.
203+
# Backslash-containing forms use the full context rather than risk
204+
# selecting a different set of sources.
205+
raise ValueError("escaped shell COPY/ADD requires full context")
206+
values = shlex.split(remaining, posix=True)
207+
if len(values) < 2:
208+
raise ValueError("COPY/ADD is missing source or destination")
209+
return flags, values
210+
211+
212+
def _split_leading_flags(body: str) -> Tuple[List[Tuple[str, str]], str]:
213+
flags = []
214+
remaining = body.lstrip()
215+
while remaining.startswith("--"):
216+
raw_token, remaining = _take_word(remaining)
217+
decoded = shlex.split(raw_token, posix=True)
218+
if len(decoded) != 1 or not decoded[0].startswith("--"):
219+
raise ValueError("invalid Dockerfile instruction flag")
220+
key, has_value, value = decoded[0][2:].partition("=")
221+
key = key.lower()
222+
if not _FLAG_NAME_PATTERN.fullmatch(key):
223+
raise ValueError("invalid Dockerfile instruction flag")
224+
if key == "mount" and any(character in raw_token for character in ('"', "'")):
225+
# BuildKit parses mount values as CSV after Dockerfile word
226+
# processing. Quoted CSV values are deliberately outside the
227+
# subset implemented here.
228+
raise ValueError("quoted RUN mount requires full context")
229+
flags.append((key, value if has_value else ""))
230+
remaining = remaining.lstrip()
231+
return flags, remaining
232+
233+
234+
def _take_word(value: str) -> Tuple[str, str]:
235+
quote: Optional[str] = None
236+
escaped = False
237+
for index, character in enumerate(value):
238+
if escaped:
239+
escaped = False
240+
continue
241+
if character == "\\" and quote != "'":
242+
escaped = True
243+
continue
244+
if quote is not None:
245+
if character == quote:
246+
quote = None
247+
continue
248+
if character in ('"', "'"):
249+
quote = character
250+
elif character.isspace():
251+
return value[:index], value[index:]
252+
if quote is not None or escaped:
253+
raise ValueError("unterminated Dockerfile instruction word")
254+
return value, ""
255+
256+
257+
def _parse_mount_options(value: str) -> Dict[str, str]:
258+
options = {}
259+
for field in value.split(","):
260+
key, has_value, option_value = field.partition("=")
261+
key = key.strip().lower()
262+
if not key:
263+
raise _AnalysisFallback("run_mount_parse_failed")
264+
options[key] = option_value.strip() if has_value else ""
265+
return options
266+
267+
268+
def _source_has_pattern(source: str) -> bool:
269+
return any(character in source for character in ("*", "?", "["))
270+
271+
272+
def _is_official_dockerfile_frontend(reference: str) -> bool:
273+
normalized = reference.strip()
274+
if normalized.startswith("docker-image://"):
275+
normalized = normalized[len("docker-image://") :]
276+
normalized = normalized.split("@", 1)[0]
277+
last_slash = normalized.rfind("/")
278+
last_colon = normalized.rfind(":")
279+
if last_colon > last_slash:
280+
normalized = normalized[:last_colon]
281+
return normalized in {
282+
"docker/dockerfile",
283+
"docker.io/docker/dockerfile",
284+
"index.docker.io/docker/dockerfile",
285+
"docker/dockerfile-upstream",
286+
"docker.io/docker/dockerfile-upstream",
287+
"index.docker.io/docker/dockerfile-upstream",
288+
}
289+
290+
291+
def _is_remote_add_source(source: str) -> bool:
292+
if _SCP_GIT_SOURCE_PATTERN.match(source):
293+
return True
294+
try:
295+
parsed = urlsplit(source)
296+
except ValueError:
297+
return False
298+
return bool(parsed.scheme and parsed.scheme.lower() != "file")

0 commit comments

Comments
 (0)