pyahead 0.2.1__py3-none-any.whl

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.
Files changed (193) hide show
  1. pyahead/__init__.py +5 -0
  2. pyahead/__main__.py +5 -0
  3. pyahead/_human_text.py +95 -0
  4. pyahead/_rooted_reader.py +259 -0
  5. pyahead/_windows_output.py +680 -0
  6. pyahead/analysis/__init__.py +6 -0
  7. pyahead/analysis/discovery.py +798 -0
  8. pyahead/analysis/engine.py +1896 -0
  9. pyahead/analysis/matchers/__init__.py +5 -0
  10. pyahead/analysis/matchers/base.py +96 -0
  11. pyahead/analysis/matchers/builtins.py +29 -0
  12. pyahead/analysis/matchers/calls.py +248 -0
  13. pyahead/analysis/matchers/imports.py +84 -0
  14. pyahead/analysis/matchers/qualified.py +110 -0
  15. pyahead/analysis/reachability.py +370 -0
  16. pyahead/analysis/suppressions.py +418 -0
  17. pyahead/baseline.py +203 -0
  18. pyahead/cli.py +680 -0
  19. pyahead/config.py +765 -0
  20. pyahead/data/__init__.py +1 -0
  21. pyahead/data/registry/__init__.py +1 -0
  22. pyahead/data/registry/coverage/cpython-101773.yaml +15 -0
  23. pyahead/data/registry/coverage/cpython-84131.yaml +16 -0
  24. pyahead/data/registry/coverage/cpython-94101.yaml +15 -0
  25. pyahead/data/registry/coverage/cpython-94352.yaml +15 -0
  26. pyahead/data/registry/coverage/cpython-97670.yaml +16 -0
  27. pyahead/data/registry/coverage/pep-0594.yaml +101 -0
  28. pyahead/data/registry/coverage/python-3.12-deprecated.yaml +198 -0
  29. pyahead/data/registry/coverage/python-3.12-removed.yaml +248 -0
  30. pyahead/data/registry/coverage/python-3.13-deprecated.yaml +129 -0
  31. pyahead/data/registry/coverage/python-3.13-removed.yaml +305 -0
  32. pyahead/data/registry/coverage/python-3.14-deprecated.yaml +121 -0
  33. pyahead/data/registry/coverage/python-3.14-removed.yaml +175 -0
  34. pyahead/data/registry/coverage/python-deprecations.yaml +448 -0
  35. pyahead/data/registry/cpython/CPY0001.yaml +52 -0
  36. pyahead/data/registry/cpython/CPY0002.yaml +53 -0
  37. pyahead/data/registry/cpython/CPY0003.yaml +52 -0
  38. pyahead/data/registry/cpython/CPY0004.yaml +52 -0
  39. pyahead/data/registry/cpython/CPY0005.yaml +53 -0
  40. pyahead/data/registry/cpython/CPY0006.yaml +53 -0
  41. pyahead/data/registry/cpython/CPY0007.yaml +53 -0
  42. pyahead/data/registry/cpython/CPY0008.yaml +53 -0
  43. pyahead/data/registry/cpython/CPY0009.yaml +53 -0
  44. pyahead/data/registry/cpython/CPY0010.yaml +53 -0
  45. pyahead/data/registry/cpython/CPY0011.yaml +53 -0
  46. pyahead/data/registry/cpython/CPY0012.yaml +53 -0
  47. pyahead/data/registry/cpython/CPY0013.yaml +53 -0
  48. pyahead/data/registry/cpython/CPY0014.yaml +53 -0
  49. pyahead/data/registry/cpython/CPY0015.yaml +53 -0
  50. pyahead/data/registry/cpython/CPY0016.yaml +53 -0
  51. pyahead/data/registry/cpython/CPY0017.yaml +53 -0
  52. pyahead/data/registry/cpython/CPY0018.yaml +53 -0
  53. pyahead/data/registry/cpython/CPY0019.yaml +53 -0
  54. pyahead/data/registry/cpython/CPY0020.yaml +53 -0
  55. pyahead/data/registry/cpython/CPY0021.yaml +53 -0
  56. pyahead/data/registry/cpython/CPY0022.yaml +53 -0
  57. pyahead/data/registry/cpython/CPY0023.yaml +47 -0
  58. pyahead/data/registry/cpython/CPY0024.yaml +44 -0
  59. pyahead/data/registry/cpython/CPY0025.yaml +43 -0
  60. pyahead/data/registry/cpython/CPY0026.yaml +42 -0
  61. pyahead/data/registry/cpython/CPY0027.yaml +49 -0
  62. pyahead/data/registry/cpython/CPY0028.yaml +44 -0
  63. pyahead/data/registry/cpython/CPY0029.yaml +46 -0
  64. pyahead/data/registry/cpython/CPY0030.yaml +46 -0
  65. pyahead/data/registry/cpython/CPY0031.yaml +50 -0
  66. pyahead/data/registry/cpython/CPY0032.yaml +45 -0
  67. pyahead/data/registry/cpython/CPY0033.yaml +48 -0
  68. pyahead/data/registry/cpython/CPY0034.yaml +46 -0
  69. pyahead/data/registry/cpython/CPY0035.yaml +47 -0
  70. pyahead/data/registry/cpython/CPY0036.yaml +52 -0
  71. pyahead/data/registry/cpython/CPY0037.yaml +46 -0
  72. pyahead/data/registry/cpython/CPY0038.yaml +48 -0
  73. pyahead/data/registry/cpython/CPY0039.yaml +48 -0
  74. pyahead/data/registry/cpython/CPY0040.yaml +52 -0
  75. pyahead/data/registry/cpython/CPY0041.yaml +50 -0
  76. pyahead/data/registry/cpython/CPY0042.yaml +56 -0
  77. pyahead/data/registry/cpython/CPY0043.yaml +75 -0
  78. pyahead/data/registry/cpython/CPY0044.yaml +44 -0
  79. pyahead/data/registry/cpython/CPY0045.yaml +55 -0
  80. pyahead/data/registry/cpython/CPY0046.yaml +50 -0
  81. pyahead/data/registry/cpython/CPY0047.yaml +82 -0
  82. pyahead/data/registry/cpython/CPY0049.yaml +50 -0
  83. pyahead/data/registry/cpython/CPY0050.yaml +48 -0
  84. pyahead/data/registry/cpython/CPY0051.yaml +41 -0
  85. pyahead/data/registry/cpython/CPY0052.yaml +48 -0
  86. pyahead/data/registry/cpython/CPY0053.yaml +48 -0
  87. pyahead/data/registry/cpython/CPY0054.yaml +48 -0
  88. pyahead/data/registry/cpython/CPY0055.yaml +60 -0
  89. pyahead/data/registry/cpython/CPY0056.yaml +56 -0
  90. pyahead/data/registry/cpython/CPY0057.yaml +52 -0
  91. pyahead/data/registry/cpython/CPY0058.yaml +52 -0
  92. pyahead/data/registry/cpython/CPY0059.yaml +61 -0
  93. pyahead/data/registry/cpython/CPY0060.yaml +74 -0
  94. pyahead/data/registry/cpython/CPY0061.yaml +46 -0
  95. pyahead/data/registry/cpython/CPY0062.yaml +46 -0
  96. pyahead/data/registry/cpython/CPY0063.yaml +45 -0
  97. pyahead/data/registry/cpython/CPY0064.yaml +58 -0
  98. pyahead/data/registry/cpython/CPY0065.yaml +43 -0
  99. pyahead/data/registry/cpython/CPY0066.yaml +41 -0
  100. pyahead/data/registry/cpython/CPY0067.yaml +41 -0
  101. pyahead/data/registry/cpython/CPY0068.yaml +41 -0
  102. pyahead/data/registry/cpython/CPY0069.yaml +43 -0
  103. pyahead/data/registry/cpython/CPY0070.yaml +43 -0
  104. pyahead/data/registry/cpython/CPY0071.yaml +44 -0
  105. pyahead/data/registry/cpython/CPY0072.yaml +57 -0
  106. pyahead/data/registry/cpython/CPY0073.yaml +142 -0
  107. pyahead/data/registry/cpython/CPY0074.yaml +60 -0
  108. pyahead/data/registry/cpython/CPY0075.yaml +48 -0
  109. pyahead/data/registry/cpython/CPY0076.yaml +40 -0
  110. pyahead/data/registry/cpython/CPY0077.yaml +43 -0
  111. pyahead/data/registry/cpython/CPY0078.yaml +41 -0
  112. pyahead/data/registry/cpython/CPY0079.yaml +43 -0
  113. pyahead/data/registry/cpython/CPY0080.yaml +41 -0
  114. pyahead/data/registry/cpython/CPY0082.yaml +113 -0
  115. pyahead/data/registry/cpython/CPY0083.yaml +41 -0
  116. pyahead/data/registry/cpython/CPY0084.yaml +48 -0
  117. pyahead/data/registry/cpython/CPY0085.yaml +41 -0
  118. pyahead/data/registry/cpython/CPY0086.yaml +108 -0
  119. pyahead/data/registry/cpython/CPY0087.yaml +40 -0
  120. pyahead/data/registry/cpython/CPY0088.yaml +48 -0
  121. pyahead/data/registry/cpython/CPY0089.yaml +47 -0
  122. pyahead/data/registry/cpython/CPY0090.yaml +46 -0
  123. pyahead/data/registry/cpython/CPY0091.yaml +41 -0
  124. pyahead/data/registry/cpython/CPY0092.yaml +43 -0
  125. pyahead/data/registry/cpython/CPY0093.yaml +44 -0
  126. pyahead/data/registry/cpython/CPY0094.yaml +46 -0
  127. pyahead/data/registry/cpython/CPY0095.yaml +45 -0
  128. pyahead/data/registry/cpython/CPY0096.yaml +43 -0
  129. pyahead/data/registry/cpython/CPY0097.yaml +51 -0
  130. pyahead/data/registry/cpython/CPY0098.yaml +40 -0
  131. pyahead/data/registry/cpython/CPY0099.yaml +40 -0
  132. pyahead/data/registry/cpython/CPY0100.yaml +47 -0
  133. pyahead/data/registry/cpython/CPY0101.yaml +79 -0
  134. pyahead/data/registry/cpython/CPY0102.yaml +48 -0
  135. pyahead/data/registry/cpython/CPY0103.yaml +40 -0
  136. pyahead/data/registry/cpython/CPY0104.yaml +41 -0
  137. pyahead/data/registry/cpython/CPY0105.yaml +45 -0
  138. pyahead/data/registry/cpython/CPY0106.yaml +40 -0
  139. pyahead/data/registry/cpython/CPY0107.yaml +53 -0
  140. pyahead/data/registry/cpython/CPY0108.yaml +43 -0
  141. pyahead/data/registry/cpython/CPY0109.yaml +56 -0
  142. pyahead/data/registry/cpython/CPY0110.yaml +44 -0
  143. pyahead/data/registry/cpython/CPY0111.yaml +40 -0
  144. pyahead/data/registry/cpython/CPY0112.yaml +48 -0
  145. pyahead/data/registry/cpython/CPY0113.yaml +69 -0
  146. pyahead/data/registry/cpython/CPY0114.yaml +49 -0
  147. pyahead/data/registry/cpython/CPY0115.yaml +44 -0
  148. pyahead/data/registry/cpython/CPY0116.yaml +44 -0
  149. pyahead/data/registry/cpython/CPY0117.yaml +61 -0
  150. pyahead/data/registry/cpython/CPY0118.yaml +43 -0
  151. pyahead/data/registry/cpython/CPY0119.yaml +45 -0
  152. pyahead/data/registry/cpython/CPY0120.yaml +42 -0
  153. pyahead/data/registry/cpython/CPY0121.yaml +46 -0
  154. pyahead/data/registry/cpython/CPY0122.yaml +46 -0
  155. pyahead/data/registry/cpython/CPY0123.yaml +40 -0
  156. pyahead/data/registry/cpython/CPY0124.yaml +44 -0
  157. pyahead/data/registry/cpython/CPY0125.yaml +62 -0
  158. pyahead/data/registry/cpython/CPY0126.yaml +40 -0
  159. pyahead/data/registry/cpython/CPY0127.yaml +60 -0
  160. pyahead/data/registry/cpython/CPY0128.yaml +40 -0
  161. pyahead/data/registry/cpython/CPY0129.yaml +44 -0
  162. pyahead/data/registry/cpython/CPY0131.yaml +43 -0
  163. pyahead/data/registry/cpython/CPY0132.yaml +45 -0
  164. pyahead/data/registry/cpython/CPY0133.yaml +57 -0
  165. pyahead/data/registry/cpython/CPY0135.yaml +44 -0
  166. pyahead/data/registry/cpython/CPY0136.yaml +56 -0
  167. pyahead/data/registry/cpython/CPY0137.yaml +70 -0
  168. pyahead/data/registry/index.yaml +152 -0
  169. pyahead/data/registry/releases.yaml +25 -0
  170. pyahead/data/schema/__init__.py +1 -0
  171. pyahead/data/schema/report-v1.json +1089 -0
  172. pyahead/dependencies.py +6949 -0
  173. pyahead/evidence.py +1120 -0
  174. pyahead/model.py +779 -0
  175. pyahead/output.py +324 -0
  176. pyahead/py.typed +1 -0
  177. pyahead/pytest_plugin.py +469 -0
  178. pyahead/registry/__init__.py +7 -0
  179. pyahead/registry/loader.py +537 -0
  180. pyahead/registry/presentation.py +264 -0
  181. pyahead/registry/schema.py +1684 -0
  182. pyahead/reporting/__init__.py +7 -0
  183. pyahead/reporting/console.py +413 -0
  184. pyahead/reporting/json.py +344 -0
  185. pyahead/reporting/sarif.py +287 -0
  186. pyahead/reporting/schema.py +466 -0
  187. pyahead/timeline.py +43 -0
  188. pyahead/versions.py +59 -0
  189. pyahead-0.2.1.dist-info/METADATA +219 -0
  190. pyahead-0.2.1.dist-info/RECORD +193 -0
  191. pyahead-0.2.1.dist-info/WHEEL +4 -0
  192. pyahead-0.2.1.dist-info/entry_points.txt +2 -0
  193. pyahead-0.2.1.dist-info/licenses/LICENSE +201 -0
pyahead/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PyAhead package metadata."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.1"
pyahead/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run PyAhead with ``python -m pyahead``."""
2
+
3
+ from pyahead.cli import main
4
+
5
+ raise SystemExit(main())
pyahead/_human_text.py ADDED
@@ -0,0 +1,95 @@
1
+ """Terminal-safe escaping for untrusted human-readable values."""
2
+
3
+ import sys
4
+ from argparse import ArgumentParser
5
+ from bisect import bisect_right
6
+ from typing import NoReturn
7
+
8
+ _UNICODE_BMP_MAX = 0xFFFF
9
+ # Generated from the Unicode 16.0.0 General_Category data by merging the Cc,
10
+ # Cf, Cs, Zl, and Zp code points into closed intervals. Keep the standalone
11
+ # controller copy byte-for-byte equivalent; its bootstrap contract forbids a
12
+ # project-runtime import.
13
+ _UNSAFE_CODEPOINT_RANGES = (
14
+ (0x0000, 0x001F),
15
+ (0x007F, 0x009F),
16
+ (0x00AD, 0x00AD),
17
+ (0x0600, 0x0605),
18
+ (0x061C, 0x061C),
19
+ (0x06DD, 0x06DD),
20
+ (0x070F, 0x070F),
21
+ (0x0890, 0x0891),
22
+ (0x08E2, 0x08E2),
23
+ (0x180E, 0x180E),
24
+ (0x200B, 0x200F),
25
+ (0x2028, 0x202E),
26
+ (0x2060, 0x2064),
27
+ (0x2066, 0x206F),
28
+ (0xD800, 0xDFFF),
29
+ (0xFEFF, 0xFEFF),
30
+ (0xFFF9, 0xFFFB),
31
+ (0x110BD, 0x110BD),
32
+ (0x110CD, 0x110CD),
33
+ (0x13430, 0x1343F),
34
+ (0x1BCA0, 0x1BCA3),
35
+ (0x1D173, 0x1D17A),
36
+ (0xE0001, 0xE0001),
37
+ (0xE0020, 0xE007F),
38
+ )
39
+ _UNSAFE_CODEPOINT_BOUNDARIES = tuple(
40
+ boundary for start, end in _UNSAFE_CODEPOINT_RANGES for boundary in (start, end + 1)
41
+ )
42
+
43
+
44
+ def _is_unsafe_terminal_codepoint(codepoint: int) -> bool:
45
+ """Use the pinned Unicode contract instead of the host Unicode database."""
46
+ return bool(bisect_right(_UNSAFE_CODEPOINT_BOUNDARIES, codepoint) & 1)
47
+
48
+
49
+ def escape_terminal_text(value: str) -> str:
50
+ """Escape controls without consuming renderer-owned structural newlines.
51
+
52
+ Callers apply this boundary to individual untrusted values before adding
53
+ headings, indentation, separators, or line endings of their own.
54
+ """
55
+ rendered: list[str] = []
56
+ for character in value:
57
+ codepoint = ord(character)
58
+ if not _is_unsafe_terminal_codepoint(codepoint):
59
+ rendered.append(character)
60
+ continue
61
+ rendered.append(
62
+ f"\\u{codepoint:04x}"
63
+ if codepoint <= _UNICODE_BMP_MAX
64
+ else f"\\U{codepoint:08x}"
65
+ )
66
+ return "".join(rendered)
67
+
68
+
69
+ class SafeArgumentParser(ArgumentParser):
70
+ """Keep argparse help and failures safe without changing parser semantics."""
71
+
72
+ def format_usage(self) -> str:
73
+ """Format structural usage lines with a terminal-safe program name."""
74
+ original = self.prog
75
+ self.prog = escape_terminal_text(original)
76
+ try:
77
+ return super().format_usage()
78
+ finally:
79
+ self.prog = original
80
+
81
+ def format_help(self) -> str:
82
+ """Format structural help lines with a terminal-safe program name."""
83
+ original = self.prog
84
+ self.prog = escape_terminal_text(original)
85
+ try:
86
+ return super().format_help()
87
+ finally:
88
+ self.prog = original
89
+
90
+ def error(self, message: str) -> NoReturn:
91
+ """Report escaped caller-controlled details with argparse's exit code."""
92
+ self.print_usage(sys.stderr)
93
+ program = escape_terminal_text(self.prog)
94
+ detail = escape_terminal_text(message)
95
+ self.exit(2, f"{program}: error: {detail}\n")
@@ -0,0 +1,259 @@
1
+ """Private root-anchored, bounded repository file input."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from contextlib import ExitStack
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from pyahead._windows_output import read_windows_rooted_file
12
+
13
+ _READ_CHUNK_BYTES = 64 * 1024
14
+
15
+
16
+ class RootedReadTooLargeError(OSError):
17
+ """Raised after a rooted input exceeds its configured byte limit."""
18
+
19
+
20
+ def repository_relative_path(root: Path, path: Path) -> Path:
21
+ """Return a lexical repository-relative path without following input links."""
22
+ absolute_root = Path(os.path.abspath(root)) # noqa: PTH100
23
+ selected = path if path.is_absolute() else absolute_root / path
24
+ absolute_selected = Path(os.path.abspath(selected)) # noqa: PTH100
25
+ try:
26
+ relative = absolute_selected.relative_to(absolute_root)
27
+ except ValueError as error:
28
+ message = "input must remain beneath the trusted root"
29
+ raise ValueError(message) from error
30
+ if relative == Path() or not relative.name or ".." in relative.parts:
31
+ message = "input must name a file beneath the trusted root"
32
+ raise ValueError(message)
33
+ return relative
34
+
35
+
36
+ def supports_rooted_descriptor_reads() -> bool:
37
+ """Return whether POSIX directory-relative, no-follow input is available."""
38
+ return bool(
39
+ os.open in os.supports_dir_fd
40
+ and os.stat in os.supports_dir_fd
41
+ and os.stat in os.supports_follow_symlinks
42
+ and hasattr(os, "O_DIRECTORY")
43
+ and hasattr(os, "O_NOFOLLOW")
44
+ )
45
+
46
+
47
+ def _directory_flags() -> int:
48
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
49
+ for name in ("O_CLOEXEC", "O_DIRECTORY", "O_NOFOLLOW", "O_NONBLOCK"):
50
+ flags |= getattr(os, name, 0)
51
+ return flags
52
+
53
+
54
+ def _file_flags() -> int:
55
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
56
+ for name in ("O_CLOEXEC", "O_NOFOLLOW", "O_NONBLOCK"):
57
+ flags |= getattr(os, name, 0)
58
+ return flags
59
+
60
+
61
+ def _same_file(left: os.stat_result, right: os.stat_result) -> bool:
62
+ return os.path.samestat(left, right)
63
+
64
+
65
+ def _same_stable_file(left: os.stat_result, right: os.stat_result) -> bool:
66
+ """Compare identity and mutation-sensitive regular-file attributes."""
67
+ return _same_file(left, right) and all(
68
+ getattr(left, field) == getattr(right, field)
69
+ for field in ("st_mode", "st_size", "st_mtime_ns", "st_ctime_ns")
70
+ )
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class _RootedReadChain:
75
+ root: Path
76
+ descriptors: tuple[int, ...]
77
+ statuses: tuple[os.stat_result, ...]
78
+ names: tuple[str, ...]
79
+
80
+ def validate(
81
+ self,
82
+ leaf_name: str,
83
+ leaf_status: os.stat_result,
84
+ leaf_descriptor: int,
85
+ ) -> None:
86
+ """Reject path bindings or file content changed during the read."""
87
+ current_root = self.root.lstat()
88
+ opened_root = os.fstat(self.descriptors[0])
89
+ if not stat.S_ISDIR(current_root.st_mode) or not (
90
+ _same_file(current_root, self.statuses[0])
91
+ and _same_file(opened_root, self.statuses[0])
92
+ ):
93
+ message = "input root changed while being read"
94
+ raise OSError(message)
95
+ for index, name in enumerate(self.names, start=1):
96
+ current = os.stat(
97
+ name,
98
+ dir_fd=self.descriptors[index - 1],
99
+ follow_symlinks=False,
100
+ )
101
+ opened = os.fstat(self.descriptors[index])
102
+ if not stat.S_ISDIR(current.st_mode) or not (
103
+ _same_file(current, self.statuses[index])
104
+ and _same_file(opened, self.statuses[index])
105
+ ):
106
+ message = "input parent changed while being read"
107
+ raise OSError(message)
108
+ current_leaf = os.stat(
109
+ leaf_name,
110
+ dir_fd=self.descriptors[-1],
111
+ follow_symlinks=False,
112
+ )
113
+ opened_leaf = os.fstat(leaf_descriptor)
114
+ if not stat.S_ISREG(current_leaf.st_mode) or not (
115
+ _same_stable_file(current_leaf, leaf_status)
116
+ and _same_stable_file(opened_leaf, leaf_status)
117
+ ):
118
+ message = "input file changed while being read"
119
+ raise OSError(message)
120
+
121
+
122
+ def _open_relative_directory(
123
+ name: str,
124
+ parent_descriptor: int,
125
+ ) -> tuple[int, os.stat_result]:
126
+ expected = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
127
+ if not stat.S_ISDIR(expected.st_mode):
128
+ message = "input parents must be real directories"
129
+ raise OSError(message)
130
+ descriptor = os.open(name, _directory_flags(), dir_fd=parent_descriptor)
131
+ try:
132
+ opened = os.fstat(descriptor)
133
+ except BaseException:
134
+ os.close(descriptor)
135
+ raise
136
+ if not stat.S_ISDIR(opened.st_mode) or not _same_file(expected, opened):
137
+ os.close(descriptor)
138
+ message = "input parent changed while being opened"
139
+ raise OSError(message)
140
+ return descriptor, opened
141
+
142
+
143
+ def _read_validated_leaf(
144
+ chain: _RootedReadChain,
145
+ leaf_name: str,
146
+ leaf_status: os.stat_result,
147
+ descriptor: int,
148
+ limit: int,
149
+ ) -> bytes:
150
+ data = bytearray()
151
+ while len(data) <= limit:
152
+ chunk = os.read(
153
+ descriptor,
154
+ min(_READ_CHUNK_BYTES, limit + 1 - len(data)),
155
+ )
156
+ if not chunk:
157
+ break
158
+ data.extend(chunk)
159
+ oversized = len(data) > limit
160
+ try:
161
+ chain.validate(leaf_name, leaf_status, descriptor)
162
+ except OSError:
163
+ if not oversized:
164
+ raise
165
+ if oversized:
166
+ message = f"input exceeds the {limit}-byte limit"
167
+ raise RootedReadTooLargeError(message)
168
+ return bytes(data)
169
+
170
+
171
+ def _read_posix_rooted_file(root: Path, relative: Path, limit: int) -> bytes:
172
+ if not supports_rooted_descriptor_reads():
173
+ message = "secure root-bounded input APIs are unavailable"
174
+ raise OSError(message)
175
+ with ExitStack() as cleanup:
176
+ descriptors: list[int] = []
177
+ statuses: list[os.stat_result] = []
178
+ names: list[str] = []
179
+ expected_root = root.lstat()
180
+ root_descriptor = os.open(root, _directory_flags())
181
+ cleanup.callback(os.close, root_descriptor)
182
+ descriptors.append(root_descriptor)
183
+ opened_root = os.fstat(root_descriptor)
184
+ if not stat.S_ISDIR(opened_root.st_mode) or not _same_file(
185
+ expected_root, opened_root
186
+ ):
187
+ message = "input root changed while being opened"
188
+ raise OSError(message)
189
+ statuses.append(opened_root)
190
+ for name in relative.parent.parts:
191
+ descriptor, opened = _open_relative_directory(name, descriptors[-1])
192
+ cleanup.callback(os.close, descriptor)
193
+ descriptors.append(descriptor)
194
+ statuses.append(opened)
195
+ names.append(name)
196
+
197
+ expected_leaf = os.stat(
198
+ relative.name,
199
+ dir_fd=descriptors[-1],
200
+ follow_symlinks=False,
201
+ )
202
+ if not stat.S_ISREG(expected_leaf.st_mode):
203
+ message = "input must be a real regular file"
204
+ raise OSError(message)
205
+ descriptor = os.open(
206
+ relative.name,
207
+ _file_flags(),
208
+ dir_fd=descriptors[-1],
209
+ )
210
+ cleanup.callback(os.close, descriptor)
211
+ opened_leaf = os.fstat(descriptor)
212
+ if not stat.S_ISREG(opened_leaf.st_mode) or not _same_file(
213
+ expected_leaf, opened_leaf
214
+ ):
215
+ message = "input file changed while being opened"
216
+ raise OSError(message)
217
+
218
+ return _read_validated_leaf(
219
+ _RootedReadChain(
220
+ root=root,
221
+ descriptors=tuple(descriptors),
222
+ statuses=tuple(statuses),
223
+ names=tuple(names),
224
+ ),
225
+ relative.name,
226
+ opened_leaf,
227
+ descriptor,
228
+ limit,
229
+ )
230
+
231
+
232
+ def read_rooted_bytes(root: Path, path: Path, limit: int) -> bytes:
233
+ """Read an unchanged regular repository file, returning at most ``limit`` bytes."""
234
+ if type(limit) is not int or limit < 0:
235
+ message = "input byte limit must be a non-negative integer"
236
+ raise ValueError(message)
237
+ absolute_root = Path(os.path.abspath(root)) # noqa: PTH100
238
+ relative = repository_relative_path(absolute_root, path)
239
+ if os.name == "nt" and any(":" in part for part in relative.parts):
240
+ message = "Windows input paths cannot name alternate data streams"
241
+ raise OSError(message)
242
+ if supports_rooted_descriptor_reads():
243
+ return _read_posix_rooted_file(absolute_root, relative, limit)
244
+ if os.name == "nt":
245
+ raw = read_windows_rooted_file(absolute_root, relative, limit)
246
+ if len(raw) > limit:
247
+ message = f"input exceeds the {limit}-byte limit"
248
+ raise RootedReadTooLargeError(message)
249
+ return raw
250
+ message = "secure root-bounded input APIs are unavailable"
251
+ raise OSError(message)
252
+
253
+
254
+ __all__ = [
255
+ "RootedReadTooLargeError",
256
+ "read_rooted_bytes",
257
+ "repository_relative_path",
258
+ "supports_rooted_descriptor_reads",
259
+ ]