|
| 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