_sanitization.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. # Copyright (c) 2025-2026 Buf Technologies, Inc.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. from __future__ import annotations
  15. import hashlib
  16. import re
  17. from typing import Final
  18. _INVALID_MODULE_COMPONENT_RE: Final[re.Pattern] = re.compile(r"[^a-zA-Z0-9_]")
  19. def escape_proto_module_component(ident: str, *, escape_with_hash: bool = False) -> str:
  20. """Escape a single component of a dotted module path.
  21. Non-alphanumeric characters are replaced with underscores. Dots are replaced with
  22. two underscores to differentiate from the dot introduced by a module path.
  23. If replacing happens and conflicts aren't allowed, we append a hash suffix from the
  24. original name to prevent collision with files that have the same sanitized name.
  25. We assume collisions with the hash are too rare and do not try to escape the hash.
  26. The first character must not be a number and is prefixed with pb__ if it is.
  27. Python keywords and the reserved `_pb` suffix are escaped by appending an
  28. underscore. Stripping trailing underscores before each check keeps the
  29. mapping injective. Leading underscore is prefixed by pb_.
  30. """
  31. component = _INVALID_MODULE_COMPONENT_RE.sub("_", ident)
  32. if component != ident and escape_with_hash:
  33. hash_suffix = hashlib.sha256(ident.encode()).hexdigest()[:8]
  34. component = f"{component}_{hash_suffix}"
  35. if component[0].isdigit():
  36. component = f"_{component}"
  37. component = escape_public_identifier(component, PYTHON_KEYWORDS)
  38. # The _pb suffix is reserved for generated proto modules (e.g. foo.proto -> foo_pb).
  39. # We must ensure the mapping is injective, so any component whose base already ends
  40. # with _pb gets an extra underscore, same as the keyword rule above.
  41. if component.rstrip("_").endswith("_pb"):
  42. return component + "_"
  43. return component
  44. PYTHON_KEYWORDS: Final[frozenset[str]] = frozenset(
  45. {
  46. "False",
  47. "None",
  48. "True",
  49. "and",
  50. "as",
  51. "assert",
  52. "async",
  53. "await",
  54. "break",
  55. "class",
  56. "continue",
  57. "def",
  58. "del",
  59. "elif",
  60. "else",
  61. "except",
  62. "finally",
  63. "for",
  64. "from",
  65. "global",
  66. "if",
  67. "import",
  68. "in",
  69. "is",
  70. "lambda",
  71. "nonlocal",
  72. "not",
  73. "or",
  74. "pass",
  75. "raise",
  76. "return",
  77. "try",
  78. "while",
  79. "with",
  80. "yield",
  81. }
  82. )
  83. # These names are not allowed for class names and escaped. Because they can be message
  84. # field names as well, the escaped form is reserved for message attributes but not the
  85. # unescaped form to prevent overescaping.
  86. _DISALLOWED_CLASS_NAMES: Final[frozenset[str]] = frozenset(
  87. {"int", "str", "bytes", "float", "bool", "list", "dict"}
  88. )
  89. _RESERVED_MESSAGE_ATTRS: Final[frozenset[str]] = frozenset(
  90. {
  91. "has_field",
  92. "clear_field",
  93. "to_binary",
  94. "to_json",
  95. "from_binary",
  96. "from_json",
  97. "desc",
  98. "self",
  99. }
  100. | PYTHON_KEYWORDS
  101. )
  102. _RESERVED_ENUM_ATTRS: Final[frozenset[str]] = frozenset(
  103. {"name", "value", "mro", "desc"} | PYTHON_KEYWORDS
  104. )
  105. _RESERVED_CLASS_NAMES: Final[frozenset[str]] = frozenset(
  106. _DISALLOWED_CLASS_NAMES | PYTHON_KEYWORDS | _RESERVED_MESSAGE_ATTRS
  107. )
  108. def escape_public_identifier(ident: str, reserved: frozenset[str]) -> str:
  109. """Escapes an identifier such as a class or attribute name.
  110. We escape for two points:
  111. - Reserved words which can cause conflicts with other code. We suffix with `_`.
  112. Identifiers that are already a reserved word with `_` suffixes also get an extra
  113. `_` to ensure the mapping is injective.
  114. - Starting with `_` which can cause visibility issues, especially if it results in
  115. name mangling from double underscores. We prefix with `pb_`. Identifiers that already
  116. start with `pb_` are also prefixed with `pb_` to ensure the mapping is injective.
  117. """
  118. if ident.rstrip("_") in reserved:
  119. ident = ident + "_"
  120. if ident.startswith(("_", "pb_")):
  121. ident = f"pb_{ident}"
  122. return ident
  123. def escape_message_attr(attr: str) -> str:
  124. """Escape a message attribute name.
  125. Examples:
  126. >>> escape_message_attr("to_json")
  127. 'to_json_'
  128. >>> escape_message_attr("_private")
  129. 'pb__private'
  130. >>> escape_message_attr("field")
  131. 'field'
  132. """
  133. name = escape_public_identifier(attr, _RESERVED_MESSAGE_ATTRS)
  134. # Only escape a suffixed value that collides with a reserved class name.
  135. if name.endswith("_") and name.rstrip("_") in _DISALLOWED_CLASS_NAMES:
  136. return name + "_"
  137. # Prevent collisions with extensions
  138. if name.startswith("ext_"):
  139. return f"pb_{name}"
  140. return name
  141. def escape_enum_attr(attr: str) -> str:
  142. """Escape an enum attribute name.
  143. Examples:
  144. >>> escape_enum_attr("name")
  145. 'name_'
  146. >>> escape_enum_attr("_private")
  147. 'pb__private'
  148. >>> escape_enum_attr("VALUE")
  149. 'VALUE'
  150. """
  151. return escape_public_identifier(attr, _RESERVED_ENUM_ATTRS)
  152. def escape_class_name(name: str) -> str:
  153. """Escape a message or enum class name.
  154. These can be either at the top level or nested in messages. So we escape against
  155. top-level identifiers we use for other generated code as well as message attributes.
  156. Examples:
  157. >>> escape_class_name("MyMessage")
  158. 'MyMessage'
  159. >>> escape_class_name("int")
  160. 'int_'
  161. >>> escape_class_name("ext_Foo")
  162. 'pb_ext_Foo'
  163. """
  164. name = escape_public_identifier(name, _RESERVED_CLASS_NAMES)
  165. # Prevent collisions with extensions
  166. if name.startswith("ext_"):
  167. return f"pb_{name}"
  168. return name
  169. def escape_extension_name(name: str) -> str:
  170. # Prefixing with ext_ automatically passes all of our sanitization requirements.
  171. return f"ext_{name}"
  172. def escape_identifier(ident: str) -> str:
  173. """Escape an arbitrary identifier.
  174. For use when creating valid Python code without other restrictions such as shadowing.
  175. Examples:
  176. >>> escape_identifier("yield")
  177. 'yield_'
  178. >>> escape_identifier("_hidden")
  179. '_hidden'
  180. >>> escape_identifier("normal")
  181. 'normal'
  182. """
  183. if ident.rstrip("_") in PYTHON_KEYWORDS:
  184. return ident + "_"
  185. return ident