voidremote 1.0.0__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.
- voidremote/__init__.py +31 -0
- voidremote/adb/__init__.py +28 -0
- voidremote/adb/client.py +385 -0
- voidremote/adb/device_parser.py +286 -0
- voidremote/cli/__init__.py +5 -0
- voidremote/cli/main.py +987 -0
- voidremote/config/__init__.py +37 -0
- voidremote/config/settings.py +203 -0
- voidremote/controllers/__init__.py +5 -0
- voidremote/controllers/app_controller.py +222 -0
- voidremote/core/__init__.py +1 -0
- voidremote/core/automation.py +184 -0
- voidremote/models/__init__.py +31 -0
- voidremote/models/device.py +263 -0
- voidremote/network/__init__.py +1 -0
- voidremote/network/discovery.py +116 -0
- voidremote/services/__init__.py +13 -0
- voidremote/services/device_service.py +340 -0
- voidremote/services/input_service.py +181 -0
- voidremote/services/monitor_service.py +198 -0
- voidremote/ui/__init__.py +1 -0
- voidremote/ui/app.py +70 -0
- voidremote/ui/dialogs/__init__.py +6 -0
- voidremote/ui/dialogs/connect_dialog.py +149 -0
- voidremote/ui/dialogs/pair_dialog.py +189 -0
- voidremote/ui/main_window.py +371 -0
- voidremote/ui/theme.py +529 -0
- voidremote/ui/views/__init__.py +15 -0
- voidremote/ui/views/dashboard_view.py +233 -0
- voidremote/ui/views/files_view.py +305 -0
- voidremote/ui/views/monitor_view.py +236 -0
- voidremote/ui/views/settings_view.py +257 -0
- voidremote/ui/views/shell_view.py +234 -0
- voidremote/ui/widgets/__init__.py +14 -0
- voidremote/ui/widgets/device_card.py +259 -0
- voidremote/ui/widgets/log_view.py +159 -0
- voidremote/ui/widgets/metric_gauge.py +90 -0
- voidremote/utils/__init__.py +28 -0
- voidremote/utils/logging.py +120 -0
- voidremote/utils/security.py +216 -0
- voidremote-1.0.0.dist-info/METADATA +578 -0
- voidremote-1.0.0.dist-info/RECORD +46 -0
- voidremote-1.0.0.dist-info/WHEEL +5 -0
- voidremote-1.0.0.dist-info/entry_points.txt +5 -0
- voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
- voidremote-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Circular gauge widget for CPU/RAM/Battery monitoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import Qt, QRectF
|
|
9
|
+
from PySide6.QtGui import QColor, QPainter, QPen, QFont, QBrush
|
|
10
|
+
from PySide6.QtWidgets import QWidget
|
|
11
|
+
|
|
12
|
+
from voidremote.ui.theme import Colors
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MetricGauge(QWidget):
|
|
16
|
+
"""
|
|
17
|
+
Circular progress gauge for displaying a percentage metric.
|
|
18
|
+
|
|
19
|
+
Shows value, label, and color-coded arc.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
label: str = "",
|
|
25
|
+
color: str = Colors.ACCENT,
|
|
26
|
+
parent: Optional[QWidget] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(parent)
|
|
29
|
+
self._label = label
|
|
30
|
+
self._color = color
|
|
31
|
+
self._value = 0.0
|
|
32
|
+
self._suffix = "%"
|
|
33
|
+
self.setMinimumSize(100, 100)
|
|
34
|
+
|
|
35
|
+
def set_value(self, value: float) -> None:
|
|
36
|
+
self._value = max(0.0, min(100.0, value))
|
|
37
|
+
self.update()
|
|
38
|
+
|
|
39
|
+
def set_label(self, label: str) -> None:
|
|
40
|
+
self._label = label
|
|
41
|
+
self.update()
|
|
42
|
+
|
|
43
|
+
def set_suffix(self, suffix: str) -> None:
|
|
44
|
+
self._suffix = suffix
|
|
45
|
+
self.update()
|
|
46
|
+
|
|
47
|
+
def paintEvent(self, event: object) -> None: # type: ignore[override]
|
|
48
|
+
painter = QPainter(self)
|
|
49
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
50
|
+
|
|
51
|
+
w = self.width()
|
|
52
|
+
h = self.height()
|
|
53
|
+
size = min(w, h)
|
|
54
|
+
margin = size * 0.1
|
|
55
|
+
arc_rect = QRectF(margin, margin, size - 2 * margin, size - 2 * margin)
|
|
56
|
+
|
|
57
|
+
# Background track
|
|
58
|
+
pen = QPen(QColor(Colors.BG_ELEVATED))
|
|
59
|
+
pen.setWidth(int(size * 0.08))
|
|
60
|
+
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
61
|
+
painter.setPen(pen)
|
|
62
|
+
painter.drawArc(arc_rect, 225 * 16, -270 * 16)
|
|
63
|
+
|
|
64
|
+
# Value arc
|
|
65
|
+
if self._value > 0:
|
|
66
|
+
val_color = QColor(self._color)
|
|
67
|
+
val_pen = QPen(val_color)
|
|
68
|
+
val_pen.setWidth(int(size * 0.08))
|
|
69
|
+
val_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
70
|
+
painter.setPen(val_pen)
|
|
71
|
+
span = int(-270 * 16 * self._value / 100.0)
|
|
72
|
+
painter.drawArc(arc_rect, 225 * 16, span)
|
|
73
|
+
|
|
74
|
+
# Center text — value
|
|
75
|
+
painter.setPen(QPen(QColor(Colors.TEXT_PRIMARY)))
|
|
76
|
+
value_font = QFont("Inter, Segoe UI", int(size * 0.2), QFont.Weight.Bold)
|
|
77
|
+
painter.setFont(value_font)
|
|
78
|
+
center_rect = QRectF(0, h * 0.3, w, h * 0.3)
|
|
79
|
+
painter.drawText(
|
|
80
|
+
center_rect,
|
|
81
|
+
Qt.AlignmentFlag.AlignCenter,
|
|
82
|
+
f"{self._value:.0f}{self._suffix}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Label below value
|
|
86
|
+
painter.setPen(QPen(QColor(Colors.TEXT_MUTED)))
|
|
87
|
+
label_font = QFont("Inter, Segoe UI", int(size * 0.1))
|
|
88
|
+
painter.setFont(label_font)
|
|
89
|
+
label_rect = QRectF(0, h * 0.58, w, h * 0.2)
|
|
90
|
+
painter.drawText(label_rect, Qt.AlignmentFlag.AlignCenter, self._label)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""VoidRemote utility modules."""
|
|
2
|
+
|
|
3
|
+
from voidremote.utils.logging import get_logger, setup_logging
|
|
4
|
+
from voidremote.utils.security import (
|
|
5
|
+
sanitize_shell_arg,
|
|
6
|
+
sanitize_shell_args,
|
|
7
|
+
validate_device_path,
|
|
8
|
+
validate_host,
|
|
9
|
+
validate_ip_address,
|
|
10
|
+
validate_local_path,
|
|
11
|
+
validate_package_name,
|
|
12
|
+
validate_pairing_code,
|
|
13
|
+
validate_port,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"get_logger",
|
|
18
|
+
"setup_logging",
|
|
19
|
+
"sanitize_shell_arg",
|
|
20
|
+
"sanitize_shell_args",
|
|
21
|
+
"validate_device_path",
|
|
22
|
+
"validate_host",
|
|
23
|
+
"validate_ip_address",
|
|
24
|
+
"validate_local_path",
|
|
25
|
+
"validate_package_name",
|
|
26
|
+
"validate_pairing_code",
|
|
27
|
+
"validate_port",
|
|
28
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured logging setup for VoidRemote.
|
|
3
|
+
|
|
4
|
+
Configures rotating file logs and colored console output with
|
|
5
|
+
consistent formatting across the entire application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import logging.handlers
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
# ANSI color codes for terminal output
|
|
17
|
+
COLORS = {
|
|
18
|
+
"RESET": "\033[0m",
|
|
19
|
+
"BOLD": "\033[1m",
|
|
20
|
+
"DIM": "\033[2m",
|
|
21
|
+
"RED": "\033[91m",
|
|
22
|
+
"GREEN": "\033[92m",
|
|
23
|
+
"YELLOW": "\033[93m",
|
|
24
|
+
"BLUE": "\033[94m",
|
|
25
|
+
"MAGENTA": "\033[95m",
|
|
26
|
+
"CYAN": "\033[96m",
|
|
27
|
+
"WHITE": "\033[97m",
|
|
28
|
+
"GREY": "\033[90m",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
LEVEL_COLORS: dict[int, str] = {
|
|
32
|
+
logging.DEBUG: COLORS["CYAN"],
|
|
33
|
+
logging.INFO: COLORS["GREEN"],
|
|
34
|
+
logging.WARNING: COLORS["YELLOW"],
|
|
35
|
+
logging.ERROR: COLORS["RED"],
|
|
36
|
+
logging.CRITICAL: COLORS["MAGENTA"] + COLORS["BOLD"],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ColoredFormatter(logging.Formatter):
|
|
41
|
+
"""Log formatter that adds ANSI color to console output."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, fmt: Optional[str] = None, use_color: bool = True) -> None:
|
|
44
|
+
super().__init__(fmt)
|
|
45
|
+
self.use_color = use_color and sys.stdout.isatty()
|
|
46
|
+
|
|
47
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
48
|
+
original_levelname = record.levelname
|
|
49
|
+
if self.use_color:
|
|
50
|
+
color = LEVEL_COLORS.get(record.levelno, COLORS["RESET"])
|
|
51
|
+
record.levelname = f"{color}{record.levelname:<8}{COLORS['RESET']}"
|
|
52
|
+
record.name = f"{COLORS['BLUE']}{record.name}{COLORS['RESET']}"
|
|
53
|
+
result = super().format(record)
|
|
54
|
+
record.levelname = original_levelname
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def setup_logging(
|
|
59
|
+
level: str = "INFO",
|
|
60
|
+
log_file: Optional[Path] = None,
|
|
61
|
+
max_bytes: int = 10 * 1024 * 1024,
|
|
62
|
+
backup_count: int = 5,
|
|
63
|
+
console: bool = True,
|
|
64
|
+
console_color: bool = True,
|
|
65
|
+
fmt: Optional[str] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Configure application-wide logging.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
level: Log level string (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
72
|
+
log_file: Optional path to write rotating log file.
|
|
73
|
+
max_bytes: Max size of each log file before rotation.
|
|
74
|
+
backup_count: Number of backup log files to keep.
|
|
75
|
+
console: Whether to output logs to stderr.
|
|
76
|
+
console_color: Whether to colorize console output.
|
|
77
|
+
fmt: Custom log format string.
|
|
78
|
+
"""
|
|
79
|
+
root = logging.getLogger()
|
|
80
|
+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
81
|
+
|
|
82
|
+
# Remove existing handlers to avoid duplicates on re-init
|
|
83
|
+
root.handlers.clear()
|
|
84
|
+
|
|
85
|
+
log_format = fmt or "%(asctime)s | %(levelname)-8s | %(name)-30s | %(message)s"
|
|
86
|
+
date_format = "%Y-%m-%d %H:%M:%S"
|
|
87
|
+
|
|
88
|
+
if console:
|
|
89
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
90
|
+
console_handler.setFormatter(
|
|
91
|
+
ColoredFormatter(fmt=log_format, use_color=console_color)
|
|
92
|
+
)
|
|
93
|
+
root.addHandler(console_handler)
|
|
94
|
+
|
|
95
|
+
if log_file is not None:
|
|
96
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
98
|
+
log_file,
|
|
99
|
+
maxBytes=max_bytes,
|
|
100
|
+
backupCount=backup_count,
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
)
|
|
103
|
+
file_handler.setFormatter(
|
|
104
|
+
logging.Formatter(fmt=log_format, datefmt=date_format)
|
|
105
|
+
)
|
|
106
|
+
root.addHandler(file_handler)
|
|
107
|
+
|
|
108
|
+
# Silence noisy third-party loggers
|
|
109
|
+
logging.getLogger("PIL").setLevel(logging.WARNING)
|
|
110
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
111
|
+
logging.getLogger("adb_shell").setLevel(logging.WARNING)
|
|
112
|
+
|
|
113
|
+
logging.getLogger(__name__).debug(
|
|
114
|
+
"Logging configured: level=%s, file=%s", level, log_file
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_logger(name: str) -> logging.Logger:
|
|
119
|
+
"""Return a logger for the given module name."""
|
|
120
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security utilities for VoidRemote.
|
|
3
|
+
|
|
4
|
+
Input validation, command injection prevention, and safe operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Characters that must not appear in ADB shell arguments
|
|
17
|
+
_SHELL_INJECTION_PATTERN = re.compile(r"[;&|`$<>\\]")
|
|
18
|
+
_SAFE_PACKAGE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9._]{1,255}$")
|
|
19
|
+
_SAFE_FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-. ()]+$')
|
|
20
|
+
_PORT_RE = re.compile(r"^\d{1,5}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_ip_address(address: str) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Validate that a string is a valid IPv4 or IPv6 address.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
address: IP address string to validate.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The validated address string.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If the address is not valid.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
parsed = ipaddress.ip_address(address)
|
|
38
|
+
return str(parsed)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
raise ValueError(f"Invalid IP address: {address!r}") from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_host(host: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Validate a host (IP or hostname) for use in ADB connections.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
host: Hostname or IP address.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Stripped, validated host string.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If host is empty or contains invalid characters.
|
|
55
|
+
"""
|
|
56
|
+
host = host.strip()
|
|
57
|
+
if not host:
|
|
58
|
+
raise ValueError("Host cannot be empty")
|
|
59
|
+
if len(host) > 253:
|
|
60
|
+
raise ValueError("Hostname too long")
|
|
61
|
+
# Try IP address first
|
|
62
|
+
try:
|
|
63
|
+
return validate_ip_address(host)
|
|
64
|
+
except ValueError:
|
|
65
|
+
pass
|
|
66
|
+
# Validate hostname (RFC 1123)
|
|
67
|
+
hostname_re = re.compile(
|
|
68
|
+
r"^(?:[a-zA-Z0-9]"
|
|
69
|
+
r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
|
|
70
|
+
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)?$"
|
|
71
|
+
)
|
|
72
|
+
if not hostname_re.match(host):
|
|
73
|
+
raise ValueError(f"Invalid hostname: {host!r}")
|
|
74
|
+
return host
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_port(port: int | str) -> int:
|
|
78
|
+
"""
|
|
79
|
+
Validate a network port number.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
port: Port as integer or string.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Validated port as integer.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
ValueError: If port is out of range.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
p = int(port)
|
|
92
|
+
except (TypeError, ValueError) as exc:
|
|
93
|
+
raise ValueError(f"Port must be an integer, got: {port!r}") from exc
|
|
94
|
+
if not (1 <= p <= 65535):
|
|
95
|
+
raise ValueError(f"Port must be 1-65535, got: {p}")
|
|
96
|
+
return p
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_pairing_code(code: str) -> str:
|
|
100
|
+
"""
|
|
101
|
+
Validate an ADB wireless pairing code.
|
|
102
|
+
|
|
103
|
+
Pairing codes are typically 6 digits.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
code: Pairing code string.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Validated code (stripped).
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: If code format is invalid.
|
|
113
|
+
"""
|
|
114
|
+
code = code.strip()
|
|
115
|
+
if not code:
|
|
116
|
+
raise ValueError("Pairing code cannot be empty")
|
|
117
|
+
if not re.match(r"^\d{6}$", code):
|
|
118
|
+
raise ValueError("Pairing code must be exactly 6 digits")
|
|
119
|
+
return code
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sanitize_shell_arg(arg: str) -> str:
|
|
123
|
+
"""
|
|
124
|
+
Sanitize a single argument intended for ADB shell execution.
|
|
125
|
+
|
|
126
|
+
Raises ValueError if the argument contains injection characters.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
arg: Shell argument to sanitize.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Shell-quoted safe string.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
ValueError: If arg contains shell metacharacters.
|
|
136
|
+
"""
|
|
137
|
+
if _SHELL_INJECTION_PATTERN.search(arg):
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Shell argument contains forbidden characters: {arg!r}"
|
|
140
|
+
)
|
|
141
|
+
return shlex.quote(arg)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sanitize_shell_args(args: list[str]) -> list[str]:
|
|
145
|
+
"""Sanitize a list of shell arguments."""
|
|
146
|
+
return [sanitize_shell_arg(a) for a in args]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def validate_package_name(package: str) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Validate an Android package name.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
package: Android package name (e.g. com.example.app).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Validated package name.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If package name is invalid.
|
|
161
|
+
"""
|
|
162
|
+
package = package.strip()
|
|
163
|
+
if not _SAFE_PACKAGE_PATTERN.match(package):
|
|
164
|
+
raise ValueError(f"Invalid Android package name: {package!r}")
|
|
165
|
+
return package
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate_device_path(path: str) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Validate a path on the Android device.
|
|
171
|
+
|
|
172
|
+
Ensures it's an absolute path without null bytes or parent traversal tricks.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
path: Absolute device path.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Validated path string.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ValueError: If path is unsafe.
|
|
182
|
+
"""
|
|
183
|
+
if not path.startswith("/"):
|
|
184
|
+
raise ValueError(f"Device path must be absolute, got: {path!r}")
|
|
185
|
+
if "\x00" in path:
|
|
186
|
+
raise ValueError("Device path contains null byte")
|
|
187
|
+
# Normalize to remove // or trailing /
|
|
188
|
+
p = Path(path)
|
|
189
|
+
normalized = str(p)
|
|
190
|
+
if ".." in normalized.split("/"):
|
|
191
|
+
raise ValueError(f"Device path contains parent traversal: {path!r}")
|
|
192
|
+
return normalized
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_local_path(path: Path) -> Path:
|
|
196
|
+
"""
|
|
197
|
+
Validate a local file system path.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
path: Local path to validate.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Resolved path.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError: If path is unsafe or doesn't exist for reading.
|
|
207
|
+
"""
|
|
208
|
+
resolved = path.resolve()
|
|
209
|
+
if "\x00" in str(resolved):
|
|
210
|
+
raise ValueError("Path contains null byte")
|
|
211
|
+
return resolved
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def is_safe_filename(name: str) -> bool:
|
|
215
|
+
"""Return True if name is safe for use as a file/directory name."""
|
|
216
|
+
return bool(_SAFE_FILENAME_PATTERN.match(name)) and ".." not in name
|