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.
- pyahead/__init__.py +5 -0
- pyahead/__main__.py +5 -0
- pyahead/_human_text.py +95 -0
- pyahead/_rooted_reader.py +259 -0
- pyahead/_windows_output.py +680 -0
- pyahead/analysis/__init__.py +6 -0
- pyahead/analysis/discovery.py +798 -0
- pyahead/analysis/engine.py +1896 -0
- pyahead/analysis/matchers/__init__.py +5 -0
- pyahead/analysis/matchers/base.py +96 -0
- pyahead/analysis/matchers/builtins.py +29 -0
- pyahead/analysis/matchers/calls.py +248 -0
- pyahead/analysis/matchers/imports.py +84 -0
- pyahead/analysis/matchers/qualified.py +110 -0
- pyahead/analysis/reachability.py +370 -0
- pyahead/analysis/suppressions.py +418 -0
- pyahead/baseline.py +203 -0
- pyahead/cli.py +680 -0
- pyahead/config.py +765 -0
- pyahead/data/__init__.py +1 -0
- pyahead/data/registry/__init__.py +1 -0
- pyahead/data/registry/coverage/cpython-101773.yaml +15 -0
- pyahead/data/registry/coverage/cpython-84131.yaml +16 -0
- pyahead/data/registry/coverage/cpython-94101.yaml +15 -0
- pyahead/data/registry/coverage/cpython-94352.yaml +15 -0
- pyahead/data/registry/coverage/cpython-97670.yaml +16 -0
- pyahead/data/registry/coverage/pep-0594.yaml +101 -0
- pyahead/data/registry/coverage/python-3.12-deprecated.yaml +198 -0
- pyahead/data/registry/coverage/python-3.12-removed.yaml +248 -0
- pyahead/data/registry/coverage/python-3.13-deprecated.yaml +129 -0
- pyahead/data/registry/coverage/python-3.13-removed.yaml +305 -0
- pyahead/data/registry/coverage/python-3.14-deprecated.yaml +121 -0
- pyahead/data/registry/coverage/python-3.14-removed.yaml +175 -0
- pyahead/data/registry/coverage/python-deprecations.yaml +448 -0
- pyahead/data/registry/cpython/CPY0001.yaml +52 -0
- pyahead/data/registry/cpython/CPY0002.yaml +53 -0
- pyahead/data/registry/cpython/CPY0003.yaml +52 -0
- pyahead/data/registry/cpython/CPY0004.yaml +52 -0
- pyahead/data/registry/cpython/CPY0005.yaml +53 -0
- pyahead/data/registry/cpython/CPY0006.yaml +53 -0
- pyahead/data/registry/cpython/CPY0007.yaml +53 -0
- pyahead/data/registry/cpython/CPY0008.yaml +53 -0
- pyahead/data/registry/cpython/CPY0009.yaml +53 -0
- pyahead/data/registry/cpython/CPY0010.yaml +53 -0
- pyahead/data/registry/cpython/CPY0011.yaml +53 -0
- pyahead/data/registry/cpython/CPY0012.yaml +53 -0
- pyahead/data/registry/cpython/CPY0013.yaml +53 -0
- pyahead/data/registry/cpython/CPY0014.yaml +53 -0
- pyahead/data/registry/cpython/CPY0015.yaml +53 -0
- pyahead/data/registry/cpython/CPY0016.yaml +53 -0
- pyahead/data/registry/cpython/CPY0017.yaml +53 -0
- pyahead/data/registry/cpython/CPY0018.yaml +53 -0
- pyahead/data/registry/cpython/CPY0019.yaml +53 -0
- pyahead/data/registry/cpython/CPY0020.yaml +53 -0
- pyahead/data/registry/cpython/CPY0021.yaml +53 -0
- pyahead/data/registry/cpython/CPY0022.yaml +53 -0
- pyahead/data/registry/cpython/CPY0023.yaml +47 -0
- pyahead/data/registry/cpython/CPY0024.yaml +44 -0
- pyahead/data/registry/cpython/CPY0025.yaml +43 -0
- pyahead/data/registry/cpython/CPY0026.yaml +42 -0
- pyahead/data/registry/cpython/CPY0027.yaml +49 -0
- pyahead/data/registry/cpython/CPY0028.yaml +44 -0
- pyahead/data/registry/cpython/CPY0029.yaml +46 -0
- pyahead/data/registry/cpython/CPY0030.yaml +46 -0
- pyahead/data/registry/cpython/CPY0031.yaml +50 -0
- pyahead/data/registry/cpython/CPY0032.yaml +45 -0
- pyahead/data/registry/cpython/CPY0033.yaml +48 -0
- pyahead/data/registry/cpython/CPY0034.yaml +46 -0
- pyahead/data/registry/cpython/CPY0035.yaml +47 -0
- pyahead/data/registry/cpython/CPY0036.yaml +52 -0
- pyahead/data/registry/cpython/CPY0037.yaml +46 -0
- pyahead/data/registry/cpython/CPY0038.yaml +48 -0
- pyahead/data/registry/cpython/CPY0039.yaml +48 -0
- pyahead/data/registry/cpython/CPY0040.yaml +52 -0
- pyahead/data/registry/cpython/CPY0041.yaml +50 -0
- pyahead/data/registry/cpython/CPY0042.yaml +56 -0
- pyahead/data/registry/cpython/CPY0043.yaml +75 -0
- pyahead/data/registry/cpython/CPY0044.yaml +44 -0
- pyahead/data/registry/cpython/CPY0045.yaml +55 -0
- pyahead/data/registry/cpython/CPY0046.yaml +50 -0
- pyahead/data/registry/cpython/CPY0047.yaml +82 -0
- pyahead/data/registry/cpython/CPY0049.yaml +50 -0
- pyahead/data/registry/cpython/CPY0050.yaml +48 -0
- pyahead/data/registry/cpython/CPY0051.yaml +41 -0
- pyahead/data/registry/cpython/CPY0052.yaml +48 -0
- pyahead/data/registry/cpython/CPY0053.yaml +48 -0
- pyahead/data/registry/cpython/CPY0054.yaml +48 -0
- pyahead/data/registry/cpython/CPY0055.yaml +60 -0
- pyahead/data/registry/cpython/CPY0056.yaml +56 -0
- pyahead/data/registry/cpython/CPY0057.yaml +52 -0
- pyahead/data/registry/cpython/CPY0058.yaml +52 -0
- pyahead/data/registry/cpython/CPY0059.yaml +61 -0
- pyahead/data/registry/cpython/CPY0060.yaml +74 -0
- pyahead/data/registry/cpython/CPY0061.yaml +46 -0
- pyahead/data/registry/cpython/CPY0062.yaml +46 -0
- pyahead/data/registry/cpython/CPY0063.yaml +45 -0
- pyahead/data/registry/cpython/CPY0064.yaml +58 -0
- pyahead/data/registry/cpython/CPY0065.yaml +43 -0
- pyahead/data/registry/cpython/CPY0066.yaml +41 -0
- pyahead/data/registry/cpython/CPY0067.yaml +41 -0
- pyahead/data/registry/cpython/CPY0068.yaml +41 -0
- pyahead/data/registry/cpython/CPY0069.yaml +43 -0
- pyahead/data/registry/cpython/CPY0070.yaml +43 -0
- pyahead/data/registry/cpython/CPY0071.yaml +44 -0
- pyahead/data/registry/cpython/CPY0072.yaml +57 -0
- pyahead/data/registry/cpython/CPY0073.yaml +142 -0
- pyahead/data/registry/cpython/CPY0074.yaml +60 -0
- pyahead/data/registry/cpython/CPY0075.yaml +48 -0
- pyahead/data/registry/cpython/CPY0076.yaml +40 -0
- pyahead/data/registry/cpython/CPY0077.yaml +43 -0
- pyahead/data/registry/cpython/CPY0078.yaml +41 -0
- pyahead/data/registry/cpython/CPY0079.yaml +43 -0
- pyahead/data/registry/cpython/CPY0080.yaml +41 -0
- pyahead/data/registry/cpython/CPY0082.yaml +113 -0
- pyahead/data/registry/cpython/CPY0083.yaml +41 -0
- pyahead/data/registry/cpython/CPY0084.yaml +48 -0
- pyahead/data/registry/cpython/CPY0085.yaml +41 -0
- pyahead/data/registry/cpython/CPY0086.yaml +108 -0
- pyahead/data/registry/cpython/CPY0087.yaml +40 -0
- pyahead/data/registry/cpython/CPY0088.yaml +48 -0
- pyahead/data/registry/cpython/CPY0089.yaml +47 -0
- pyahead/data/registry/cpython/CPY0090.yaml +46 -0
- pyahead/data/registry/cpython/CPY0091.yaml +41 -0
- pyahead/data/registry/cpython/CPY0092.yaml +43 -0
- pyahead/data/registry/cpython/CPY0093.yaml +44 -0
- pyahead/data/registry/cpython/CPY0094.yaml +46 -0
- pyahead/data/registry/cpython/CPY0095.yaml +45 -0
- pyahead/data/registry/cpython/CPY0096.yaml +43 -0
- pyahead/data/registry/cpython/CPY0097.yaml +51 -0
- pyahead/data/registry/cpython/CPY0098.yaml +40 -0
- pyahead/data/registry/cpython/CPY0099.yaml +40 -0
- pyahead/data/registry/cpython/CPY0100.yaml +47 -0
- pyahead/data/registry/cpython/CPY0101.yaml +79 -0
- pyahead/data/registry/cpython/CPY0102.yaml +48 -0
- pyahead/data/registry/cpython/CPY0103.yaml +40 -0
- pyahead/data/registry/cpython/CPY0104.yaml +41 -0
- pyahead/data/registry/cpython/CPY0105.yaml +45 -0
- pyahead/data/registry/cpython/CPY0106.yaml +40 -0
- pyahead/data/registry/cpython/CPY0107.yaml +53 -0
- pyahead/data/registry/cpython/CPY0108.yaml +43 -0
- pyahead/data/registry/cpython/CPY0109.yaml +56 -0
- pyahead/data/registry/cpython/CPY0110.yaml +44 -0
- pyahead/data/registry/cpython/CPY0111.yaml +40 -0
- pyahead/data/registry/cpython/CPY0112.yaml +48 -0
- pyahead/data/registry/cpython/CPY0113.yaml +69 -0
- pyahead/data/registry/cpython/CPY0114.yaml +49 -0
- pyahead/data/registry/cpython/CPY0115.yaml +44 -0
- pyahead/data/registry/cpython/CPY0116.yaml +44 -0
- pyahead/data/registry/cpython/CPY0117.yaml +61 -0
- pyahead/data/registry/cpython/CPY0118.yaml +43 -0
- pyahead/data/registry/cpython/CPY0119.yaml +45 -0
- pyahead/data/registry/cpython/CPY0120.yaml +42 -0
- pyahead/data/registry/cpython/CPY0121.yaml +46 -0
- pyahead/data/registry/cpython/CPY0122.yaml +46 -0
- pyahead/data/registry/cpython/CPY0123.yaml +40 -0
- pyahead/data/registry/cpython/CPY0124.yaml +44 -0
- pyahead/data/registry/cpython/CPY0125.yaml +62 -0
- pyahead/data/registry/cpython/CPY0126.yaml +40 -0
- pyahead/data/registry/cpython/CPY0127.yaml +60 -0
- pyahead/data/registry/cpython/CPY0128.yaml +40 -0
- pyahead/data/registry/cpython/CPY0129.yaml +44 -0
- pyahead/data/registry/cpython/CPY0131.yaml +43 -0
- pyahead/data/registry/cpython/CPY0132.yaml +45 -0
- pyahead/data/registry/cpython/CPY0133.yaml +57 -0
- pyahead/data/registry/cpython/CPY0135.yaml +44 -0
- pyahead/data/registry/cpython/CPY0136.yaml +56 -0
- pyahead/data/registry/cpython/CPY0137.yaml +70 -0
- pyahead/data/registry/index.yaml +152 -0
- pyahead/data/registry/releases.yaml +25 -0
- pyahead/data/schema/__init__.py +1 -0
- pyahead/data/schema/report-v1.json +1089 -0
- pyahead/dependencies.py +6949 -0
- pyahead/evidence.py +1120 -0
- pyahead/model.py +779 -0
- pyahead/output.py +324 -0
- pyahead/py.typed +1 -0
- pyahead/pytest_plugin.py +469 -0
- pyahead/registry/__init__.py +7 -0
- pyahead/registry/loader.py +537 -0
- pyahead/registry/presentation.py +264 -0
- pyahead/registry/schema.py +1684 -0
- pyahead/reporting/__init__.py +7 -0
- pyahead/reporting/console.py +413 -0
- pyahead/reporting/json.py +344 -0
- pyahead/reporting/sarif.py +287 -0
- pyahead/reporting/schema.py +466 -0
- pyahead/timeline.py +43 -0
- pyahead/versions.py +59 -0
- pyahead-0.2.1.dist-info/METADATA +219 -0
- pyahead-0.2.1.dist-info/RECORD +193 -0
- pyahead-0.2.1.dist-info/WHEEL +4 -0
- pyahead-0.2.1.dist-info/entry_points.txt +2 -0
- pyahead-0.2.1.dist-info/licenses/LICENSE +201 -0
pyahead/__init__.py
ADDED
pyahead/__main__.py
ADDED
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
|
+
]
|