#!/usr/bin/env python3 """ACK YAML 的零依赖、fail-closed 子集解析器。 这不是通用 YAML 实现。它只覆盖 ACK 状态文件所需的 mapping、 sequence、flow collection、标量和 ``>`` / ``|`` block scalar。锚点、 alias、tag、多文档和其它未实现语法会显式失败,不会猜测或静默误解析。 """ from __future__ import annotations import json import re from dataclasses import dataclass from typing import Any class YamlSubsetError(ValueError): """YAML 超出 ACK 子集或语法无效。""" class DuplicateKeyError(ValueError): """JSON/YAML mapping 包含重复键。""" @dataclass(frozen=True) class _Line: number: int indent: int content: str _DECIMAL_INT_RE = re.compile(r"[-+]?(?:0|[1-9][0-9]*)\Z") _AMBIGUOUS_NUMBER_RE = re.compile( r"[-+]?(?:" r"[0-9][0-9_]*\.[0-9_]*(?:[eE][-+]?[0-9]+)?|" r"[0-9][0-9_]*(?:[eE][-+]?[0-9]+)|" r"0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|" r"0[0-9_]+|[0-9][0-9_]*:[0-9_:]+" r")\Z" ) _PLAIN_KEY_FORBIDDEN_RE = re.compile(r"[\[\]{},#]") _ANCHOR_OR_ALIAS_RE = re.compile( r"(?:^|\s)[&*][A-Za-z0-9_-]+(?:\s|$)" ) def load_json_unique(content: str) -> Any: """Parse JSON while rejecting duplicate object keys at every depth.""" def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for key, value in pairs: if key in result: raise DuplicateKeyError(f"JSON 存在重复键 {key!r}") result[key] = value return result return json.loads(content, object_pairs_hook=unique_object) def make_unique_pyyaml_loader(yaml_module: Any) -> type: """Build a SafeLoader that rejects duplicate keys and graph features. ACK documents are trees. Anchors/aliases can introduce shared identity or cycles, which are unnecessary here and make recursive validation unsafe. Explicit tags are also outside the fallback grammar, so both code paths reject them consistently. """ class UniqueKeySafeLoader(yaml_module.SafeLoader): # type: ignore[misc] def compose_node(self, parent: Any, index: Any) -> Any: if self.check_event(yaml_module.events.AliasEvent): event = self.peek_event() raise yaml_module.YAMLError( f"ACK YAML 不支持 alias: *{event.anchor}" ) event = self.peek_event() if getattr(event, "anchor", None) is not None: raise yaml_module.YAMLError( f"ACK YAML 不支持 anchor: &{event.anchor}" ) if getattr(event, "tag", None) is not None: raise yaml_module.YAMLError( f"ACK YAML 不支持显式 tag: {event.tag}" ) return super().compose_node(parent, index) def construct_unique_mapping( loader: Any, node: Any, deep: bool = False, ) -> dict[str, Any]: for key_node, _ in node.value: if ( getattr(key_node, "tag", None) == "tag:yaml.org,2002:merge" or getattr(key_node, "value", None) == "<<" ): raise yaml_module.constructor.ConstructorError( "while constructing an ACK mapping", node.start_mark, "merge keys are not supported", key_node.start_mark, ) loader.flatten_mapping(node) mapping: dict[str, Any] = {} for key_node, value_node in node.value: key = loader.construct_object(key_node, deep=deep) if not isinstance(key, str): raise yaml_module.constructor.ConstructorError( "while constructing an ACK mapping", node.start_mark, "mapping key must be a string", key_node.start_mark, ) if key == "<<": raise yaml_module.constructor.ConstructorError( "while constructing an ACK mapping", node.start_mark, "merge keys are not supported", key_node.start_mark, ) if key in mapping: raise yaml_module.constructor.ConstructorError( "while constructing an ACK mapping", node.start_mark, f"found duplicate key {key!r}", key_node.start_mark, ) mapping[key] = loader.construct_object(value_node, deep=deep) return mapping UniqueKeySafeLoader.add_constructor( yaml_module.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_unique_mapping, ) return UniqueKeySafeLoader def load_yaml_subset(content: str) -> Any: """Parse the deliberately small YAML subset used by ACK files.""" return _SubsetParser(content).parse() class _SubsetParser: def __init__(self, content: str) -> None: if content.startswith("\ufeff"): content = content[1:] if "\t" in content: raise YamlSubsetError("ACK YAML 子集不支持 Tab,请使用空格") self.lines = [ _Line(number, len(raw) - len(raw.lstrip(" ")), raw.lstrip(" ")) for number, raw in enumerate(content.splitlines(), start=1) ] self.index = 0 def parse(self) -> Any: self._skip_insignificant() if self.index >= len(self.lines): return None first = self.lines[self.index] if first.indent != 0: self._error(first, "顶层不能缩进") value = self._parse_node(0) self._skip_insignificant() if self.index != len(self.lines): line = self.lines[self.index] self._error(line, "文档尾部存在无法解析的内容") return value def _parse_node(self, indent: int) -> Any: self._skip_insignificant() if self.index >= len(self.lines): return None line = self.lines[self.index] if line.indent != indent: self._error(line, f"期望 {indent} 个空格的缩进") content = self._without_comment(line.content).rstrip() self._reject_document_syntax(content, line) if self._is_sequence_marker(content): return self._parse_sequence(indent) return self._parse_mapping(indent) def _parse_mapping(self, indent: int) -> dict[str, Any]: result: dict[str, Any] = {} while True: self._skip_insignificant() if self.index >= len(self.lines): break line = self.lines[self.index] if line.indent < indent: break if line.indent > indent: self._error(line, "mapping 存在意外缩进") content = self._without_comment(line.content).rstrip() self._reject_document_syntax(content, line) if self._is_sequence_marker(content): break self.index += 1 self._consume_mapping_entry( result, content, mapping_indent=indent, line=line, ) return result def _parse_sequence(self, indent: int) -> list[Any]: result: list[Any] = [] while True: self._skip_insignificant() if self.index >= len(self.lines): break line = self.lines[self.index] if line.indent < indent: break if line.indent > indent: self._error(line, "sequence 存在意外缩进") content = self._without_comment(line.content).rstrip() self._reject_document_syntax(content, line) if not self._is_sequence_marker(content): break rest = content[1:].lstrip(" ") self.index += 1 if not rest: next_line = self._peek_significant() if next_line is not None and next_line.indent > indent: result.append(self._parse_node(next_line.indent)) else: result.append(None) continue if self._is_sequence_marker(rest): self._error( line, "不支持紧凑嵌套 sequence,请把内层 '-' 放到下一行", ) if self._looks_like_mapping_entry(rest): mapping_indent = indent + 2 item: dict[str, Any] = {} self._consume_mapping_entry( item, rest, mapping_indent=mapping_indent, line=line, ) while True: self._skip_insignificant() continuation = self._peek_significant() if continuation is None or continuation.indent < mapping_indent: break if continuation.indent > mapping_indent: self._error( continuation, "sequence mapping 存在意外缩进", ) continuation_content = self._without_comment( continuation.content ).rstrip() if self._is_sequence_marker(continuation_content): self._error( continuation, "sequence mapping 中需要 key: value", ) self.index += 1 self._consume_mapping_entry( item, continuation_content, mapping_indent=mapping_indent, line=continuation, ) result.append(item) continue if rest in {">", "|"}: result.append(self._parse_block_scalar(indent, rest, line)) else: result.append(self._parse_inline_value(rest, line)) next_line = self._peek_significant() if next_line is not None and next_line.indent > indent: self._error(next_line, "scalar sequence 项后存在意外缩进") return result def _consume_mapping_entry( self, result: dict[str, Any], content: str, *, mapping_indent: int, line: _Line, ) -> None: key_text, value_text = self._split_mapping_entry(content, line) key = self._parse_key(key_text, line) if key in result: self._error(line, f"mapping 存在重复键 {key!r}") value_text = value_text.strip() if value_text in {">", "|"}: value = self._parse_block_scalar(mapping_indent, value_text, line) elif value_text: if value_text.startswith((">", "|")): self._error( line, "block scalar 只支持 '>' 或 '|',不支持 chomping/indent 指示符", ) value = self._parse_inline_value(value_text, line) next_line = self._peek_significant() if next_line is not None and next_line.indent > mapping_indent: self._error(next_line, f"{key!r} 的 scalar 后存在意外缩进") else: next_line = self._peek_significant() if next_line is not None and next_line.indent > mapping_indent: value = self._parse_node(next_line.indent) else: value = None result[key] = value def _parse_block_scalar( self, parent_indent: int, style: str, header: _Line, ) -> str: probe = self.index while probe < len(self.lines) and not self.lines[probe].content.strip(): probe += 1 if probe >= len(self.lines) or self.lines[probe].indent <= parent_indent: return "" block_indent = self.lines[probe].indent if block_indent <= parent_indent: self._error(header, "block scalar 内容必须比键更深缩进") values: list[str] = [] while self.index < len(self.lines): line = self.lines[self.index] if not line.content.strip(): values.append("") self.index += 1 continue if line.indent < block_indent: break values.append(" " * (line.indent - block_indent) + line.content) self.index += 1 while values and values[-1] == "": values.pop() if not values: return "" if style == "|": return "\n".join(values) + "\n" output = "" previous: str | None = None blank_count = 0 for value in values: if value == "": blank_count += 1 continue if previous is None: output = "\n" * blank_count + value elif blank_count: output += "\n" * blank_count + value elif previous.startswith(" ") or value.startswith(" "): output += "\n" + value else: output += " " + value previous = value blank_count = 0 return output + "\n" def _parse_key(self, text: str, line: _Line) -> str: text = text.strip() if not text: self._error(line, "mapping key 不能为空") if text[0] in {'"', "'"}: parsed = _FlowParser(text, line.number).parse_complete_value() if not isinstance(parsed, str): self._error(line, "mapping key 必须是字符串") return parsed if text == "<<": self._error(line, "ACK YAML 子集不支持 merge key '<<'") if text[0] in "-?:!&*%@`" or _PLAIN_KEY_FORBIDDEN_RE.search(text): self._error(line, f"不支持的 plain mapping key {text!r}") parsed = _plain_scalar(text, line.number) if not isinstance(parsed, str): self._error(line, "mapping key 必须是字符串,特殊标量请加引号") return text def _parse_inline_value(self, text: str, line: _Line) -> Any: parser = _FlowParser(text, line.number) return parser.parse_complete_value() def _split_mapping_entry(self, content: str, line: _Line) -> tuple[str, str]: quote: str | None = None escaped = False depth = 0 index = 0 while index < len(content): char = content[index] if quote == '"': if escaped: escaped = False elif char == "\\": escaped = True elif char == quote: quote = None index += 1 continue if quote == "'": if char == "'": if index + 1 < len(content) and content[index + 1] == "'": index += 2 continue quote = None index += 1 continue if char in {'"', "'"}: quote = char elif char in "[{": depth += 1 elif char in "]}": depth -= 1 if depth < 0: self._error(line, "flow collection 括号不匹配") elif ( char == ":" and depth == 0 and (index + 1 == len(content) or content[index + 1].isspace()) ): return content[:index], content[index + 1 :] index += 1 self._error(line, "mapping 项必须使用 'key: value'") def _looks_like_mapping_entry(self, content: str) -> bool: try: self._split_mapping_entry(content, self.lines[self.index - 1]) except YamlSubsetError: return False return True @staticmethod def _is_sequence_marker(content: str) -> bool: return content == "-" or content.startswith("- ") def _peek_significant(self) -> _Line | None: probe = self.index while probe < len(self.lines): line = self.lines[probe] if line.content.strip() and not line.content.lstrip().startswith("#"): return line probe += 1 return None def _skip_insignificant(self) -> None: while self.index < len(self.lines): content = self.lines[self.index].content if content.strip() and not content.lstrip().startswith("#"): break self.index += 1 def _without_comment(self, content: str) -> str: quote: str | None = None escaped = False index = 0 while index < len(content): char = content[index] if quote == '"': if escaped: escaped = False elif char == "\\": escaped = True elif char == quote: quote = None elif quote == "'": if char == "'": if index + 1 < len(content) and content[index + 1] == "'": index += 1 else: quote = None elif char in {'"', "'"}: quote = char elif char == "#" and (index == 0 or content[index - 1].isspace()): return content[:index] index += 1 if quote is not None: raise YamlSubsetError("未结束的引号标量") return content def _reject_document_syntax(self, content: str, line: _Line) -> None: if content in {"---", "..."} or content.startswith("%"): self._error(line, "ACK YAML 子集只支持单文档,不支持 directive/marker") if content.startswith(("!", "&", "*")): self._error(line, "ACK YAML 子集不支持 tag/anchor/alias") @staticmethod def _error(line: _Line, message: str) -> None: raise YamlSubsetError(f"第 {line.number} 行: {message}") class _FlowParser: def __init__(self, text: str, line_number: int) -> None: self.text = text self.line_number = line_number self.index = 0 def parse_complete_value(self) -> Any: value = self._parse_value() self._skip_space() if self.index != len(self.text): self._error(f"标量后存在未支持内容: {self.text[self.index:]!r}") return value def _parse_value(self) -> Any: self._skip_space() if self.index >= len(self.text): self._error("缺少标量") char = self.text[self.index] if char == "[": return self._parse_list() if char == "{": return self._parse_map() if char in {'"', "'"}: return self._parse_quoted() if char in "]},": self._error(f"意外字符 {char!r}") return self._parse_plain({",", "]", "}"}) def _parse_list(self) -> list[Any]: self.index += 1 result: list[Any] = [] self._skip_space() if self._consume("]"): return result while True: result.append(self._parse_value()) self._skip_space() if self._consume("]"): return result if not self._consume(","): self._error("flow list 项之间必须用 ',' 分隔") self._skip_space() if self.index >= len(self.text) or self.text[self.index] == "]": self._error("flow list 不支持尾随逗号") def _parse_map(self) -> dict[str, Any]: self.index += 1 result: dict[str, Any] = {} self._skip_space() if self._consume("}"): return result while True: self._skip_space() if self.index >= len(self.text): self._error("flow mapping 未结束") if self.text[self.index] in {'"', "'"}: key = self._parse_quoted() else: key = self._parse_plain({":"}, convert=False) if not isinstance(key, str) or not key: self._error("flow mapping key 必须是非空字符串") if key == "<<": self._error("ACK YAML 子集不支持 merge key '<<'") self._skip_space() if not self._consume(":"): self._error("flow mapping key 后必须是 ':'") value = self._parse_value() if key in result: self._error(f"flow mapping 存在重复键 {key!r}") result[key] = value self._skip_space() if self._consume("}"): return result if not self._consume(","): self._error("flow mapping 项之间必须用 ',' 分隔") self._skip_space() if self.index >= len(self.text) or self.text[self.index] == "}": self._error("flow mapping 不支持尾随逗号") def _parse_quoted(self) -> str: quote = self.text[self.index] self.index += 1 result: list[str] = [] while self.index < len(self.text): char = self.text[self.index] self.index += 1 if char == quote: if quote == "'" and self.index < len(self.text) and self.text[ self.index ] == "'": result.append("'") self.index += 1 continue return "".join(result) if quote == "'" or char != "\\": result.append(char) continue if self.index >= len(self.text): self._error("双引号标量以转义符结尾") escape = self.text[self.index] self.index += 1 simple = { "0": "\0", "a": "\a", "b": "\b", "t": "\t", "n": "\n", "v": "\v", "f": "\f", "r": "\r", "e": "\x1b", " ": " ", '"': '"', "/": "/", "\\": "\\", } if escape in simple: result.append(simple[escape]) continue widths = {"x": 2, "u": 4, "U": 8} if escape in widths: width = widths[escape] digits = self.text[self.index : self.index + width] if len(digits) != width or not re.fullmatch( rf"[0-9a-fA-F]{{{width}}}", digits ): self._error(f"无效 Unicode 转义 \\{escape}{digits}") codepoint = int(digits, 16) try: result.append(chr(codepoint)) except ValueError as exc: raise YamlSubsetError( f"第 {self.line_number} 行: 无效 Unicode 码点" ) from exc self.index += width continue self._error(f"不支持的双引号转义 \\{escape}") self._error("引号标量未结束") def _parse_plain( self, delimiters: set[str], *, convert: bool = True, ) -> Any: start = self.index while self.index < len(self.text) and self.text[self.index] not in delimiters: self.index += 1 token = self.text[start : self.index].strip() if not token: self._error("空 plain scalar") if "#" in token: self._error("flow collection 内的注释不受支持") if ": " in token: self._error("plain scalar 中的 ': ' 必须加引号") if token[0] in "!&*%@`?" or _ANCHOR_OR_ALIAS_RE.search(token): self._error("ACK YAML 子集不支持 tag/anchor/alias/directive") return _plain_scalar(token, self.line_number) if convert else token def _skip_space(self) -> None: while self.index < len(self.text) and self.text[self.index] == " ": self.index += 1 def _consume(self, expected: str) -> bool: if self.index < len(self.text) and self.text[self.index] == expected: self.index += 1 return True return False def _error(self, message: str) -> None: raise YamlSubsetError(f"第 {self.line_number} 行: {message}") def _plain_scalar(token: str, line_number: int) -> Any: lowered = token.lower() if lowered in {"null", "~"}: return None if lowered in {"true", "yes", "on"}: return True if lowered in {"false", "no", "off"}: return False if _DECIMAL_INT_RE.fullmatch(token): return int(token, 10) if _AMBIGUOUS_NUMBER_RE.fullmatch(token): raise YamlSubsetError( f"第 {line_number} 行: ACK YAML 子集不支持该数字格式 {token!r}," "如需字符串请加引号" ) return token