browserget 1.0.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.
- browserget/__init__.py +46 -0
- browserget/__main__.py +6 -0
- browserget/archive.py +114 -0
- browserget/cache.py +187 -0
- browserget/checksum.py +76 -0
- browserget/cli.py +799 -0
- browserget/config.py +101 -0
- browserget/exceptions.py +110 -0
- browserget/http.py +272 -0
- browserget/installers/__init__.py +19 -0
- browserget/installers/base.py +129 -0
- browserget/installers/chrome.py +187 -0
- browserget/installers/chromedriver.py +214 -0
- browserget/installers/edge.py +507 -0
- browserget/installers/firefox.py +336 -0
- browserget/installers/geckodriver.py +200 -0
- browserget/logging.py +51 -0
- browserget/models.py +111 -0
- browserget/parsers/__init__.py +23 -0
- browserget/parsers/cft.py +119 -0
- browserget/parsers/edge.py +203 -0
- browserget/parsers/firefox.py +114 -0
- browserget/parsers/geckodriver.py +109 -0
- browserget/platform.py +142 -0
- browserget/registry.py +186 -0
- browserget/system.py +278 -0
- browserget-1.0.1.dist-info/METADATA +165 -0
- browserget-1.0.1.dist-info/RECORD +31 -0
- browserget-1.0.1.dist-info/WHEEL +4 -0
- browserget-1.0.1.dist-info/entry_points.txt +2 -0
- browserget-1.0.1.dist-info/licenses/LICENSE +21 -0
browserget/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""browserget — Standalone CLI to install browsers and drivers without any framework."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _pkg_version
|
|
5
|
+
|
|
6
|
+
from browserget.config import Config, load_config
|
|
7
|
+
from browserget.exceptions import (
|
|
8
|
+
AlreadyInstalledError,
|
|
9
|
+
BrowsergetError,
|
|
10
|
+
ChecksumMismatchError,
|
|
11
|
+
DriverMatchError,
|
|
12
|
+
InsufficientDiskSpaceError,
|
|
13
|
+
NetworkError,
|
|
14
|
+
UnknownTargetError,
|
|
15
|
+
UnsupportedPlatformError,
|
|
16
|
+
VersionNotFoundError,
|
|
17
|
+
)
|
|
18
|
+
from browserget.models import InstalledArtifact, ResolvedVersion, SystemBrowser
|
|
19
|
+
from browserget.platform import OS, Arch, Platform, detect_platform
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = _pkg_version("browserget")
|
|
23
|
+
except PackageNotFoundError: # pragma: no cover
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AlreadyInstalledError",
|
|
28
|
+
"Arch",
|
|
29
|
+
"BrowsergetError",
|
|
30
|
+
"ChecksumMismatchError",
|
|
31
|
+
"Config",
|
|
32
|
+
"DriverMatchError",
|
|
33
|
+
"InsufficientDiskSpaceError",
|
|
34
|
+
"InstalledArtifact",
|
|
35
|
+
"NetworkError",
|
|
36
|
+
"OS",
|
|
37
|
+
"Platform",
|
|
38
|
+
"ResolvedVersion",
|
|
39
|
+
"SystemBrowser",
|
|
40
|
+
"UnknownTargetError",
|
|
41
|
+
"UnsupportedPlatformError",
|
|
42
|
+
"VersionNotFoundError",
|
|
43
|
+
"__version__",
|
|
44
|
+
"detect_platform",
|
|
45
|
+
"load_config",
|
|
46
|
+
]
|
browserget/__main__.py
ADDED
browserget/archive.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Safe archive extraction utilities with path-traversal protection.
|
|
2
|
+
|
|
3
|
+
All extraction functions validate that every entry in the archive resolves
|
|
4
|
+
to a path inside the destination directory, preventing Zip Slip and similar
|
|
5
|
+
path-traversal attacks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
import tarfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_extract_path(dest: Path, member_name: str) -> Path:
|
|
19
|
+
"""Resolve *member_name* relative to *dest* and verify it stays inside.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
dest: The destination directory.
|
|
23
|
+
member_name: The archive member's relative path.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The resolved, validated path.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If the resolved path escapes *dest*.
|
|
30
|
+
"""
|
|
31
|
+
dest_resolved = dest.resolve()
|
|
32
|
+
target = (dest / member_name).resolve()
|
|
33
|
+
try:
|
|
34
|
+
target.relative_to(dest_resolved)
|
|
35
|
+
except ValueError as exc:
|
|
36
|
+
raise ValueError(f"Archive entry '{member_name}' escapes destination directory") from exc
|
|
37
|
+
return target
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_zip(archive_path: Path, dest: Path) -> None:
|
|
41
|
+
"""Extract a zip archive safely, preventing path traversal.
|
|
42
|
+
|
|
43
|
+
Validates both member paths and symlink targets to prevent
|
|
44
|
+
path-traversal attacks via malicious symlinks.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
archive_path: Path to the zip file.
|
|
48
|
+
dest: Destination directory (must exist).
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If any entry escapes the destination directory.
|
|
52
|
+
zipfile.BadZipFile: If the archive is corrupt or not a zip file.
|
|
53
|
+
"""
|
|
54
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
56
|
+
for member in zf.infolist():
|
|
57
|
+
_safe_extract_path(dest, member.filename)
|
|
58
|
+
unix_mode = member.external_attr >> 16
|
|
59
|
+
if stat.S_ISLNK(unix_mode):
|
|
60
|
+
link_target = zf.read(member).decode("utf-8", errors="replace")
|
|
61
|
+
_safe_extract_path(dest, link_target)
|
|
62
|
+
zf.extractall(dest)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_tar(archive_path: Path, dest: Path) -> None:
|
|
66
|
+
"""Extract a tar archive safely, preventing path traversal.
|
|
67
|
+
|
|
68
|
+
Automatically detects compression (gzip, bzip2, xz, or uncompressed).
|
|
69
|
+
Validates both member paths and symlink/hardlink targets to prevent
|
|
70
|
+
path-traversal attacks via malicious symlinks.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
archive_path: Path to the tar file.
|
|
74
|
+
dest: Destination directory (must exist).
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If any entry escapes the destination directory.
|
|
78
|
+
tarfile.TarError: If the archive is corrupt.
|
|
79
|
+
"""
|
|
80
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
with tarfile.open(str(archive_path), "r:*") as tf:
|
|
82
|
+
for member in tf.getmembers():
|
|
83
|
+
_safe_extract_path(dest, member.name)
|
|
84
|
+
if member.issym() or member.islnk():
|
|
85
|
+
_safe_extract_path(dest, member.linkname)
|
|
86
|
+
if sys.version_info >= (3, 12):
|
|
87
|
+
tf.extractall(dest, filter="data")
|
|
88
|
+
else:
|
|
89
|
+
for member in tf.getmembers():
|
|
90
|
+
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
|
91
|
+
raise ValueError(f"Refusing to extract special file: {member.name!r}")
|
|
92
|
+
tf.extractall(dest)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_file_by_name(root: Path, filename: str) -> Path | None:
|
|
96
|
+
"""Find a file by name in a directory tree without following symlinks.
|
|
97
|
+
|
|
98
|
+
Uses ``os.walk(followlinks=False)`` to avoid infinite recursion from
|
|
99
|
+
circular symlinks that may exist in extracted archives.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
root: The root directory to search.
|
|
103
|
+
filename: The filename to match (basename only).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The first matching file path, or ``None`` if not found.
|
|
107
|
+
"""
|
|
108
|
+
for dirpath, _dirnames, filenames in os.walk(root, followlinks=False):
|
|
109
|
+
for fname in filenames:
|
|
110
|
+
if fname == filename:
|
|
111
|
+
candidate = Path(dirpath) / fname
|
|
112
|
+
if not candidate.is_symlink() and candidate.is_file():
|
|
113
|
+
return candidate
|
|
114
|
+
return None
|
browserget/cache.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Cache directory management for browserget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from browserget.config import load_config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_cache_dir() -> Path:
|
|
14
|
+
"""Return the cache directory, creating it if it does not exist.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
The path to the cache directory.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
OSError: If the directory cannot be created (e.g. read-only filesystem).
|
|
21
|
+
"""
|
|
22
|
+
cache_dir = load_config().cache_dir
|
|
23
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return cache_dir
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_path_component(component: str) -> str:
|
|
28
|
+
"""Validate that a string is safe to use as a single path component.
|
|
29
|
+
|
|
30
|
+
Rejects empty strings, strings containing path separators (``/`` or ``\\``),
|
|
31
|
+
and the ``..`` traversal sequence. This prevents path-traversal attacks
|
|
32
|
+
when version strings or names from upstream APIs are used to construct
|
|
33
|
+
filesystem paths.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
component: The string to validate.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The validated string.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If the component contains path separators or ``..``.
|
|
43
|
+
"""
|
|
44
|
+
if not component or component == ".." or component == ".":
|
|
45
|
+
raise ValueError(f"Unsafe path component: {component!r}")
|
|
46
|
+
if "\x00" in component:
|
|
47
|
+
raise ValueError(f"Path component contains null byte: {component!r}")
|
|
48
|
+
if "/" in component or "\\" in component:
|
|
49
|
+
raise ValueError(f"Path component contains separator: {component!r}")
|
|
50
|
+
# Reject any component that resolves to a parent path
|
|
51
|
+
if Path(component).parts != (component,):
|
|
52
|
+
raise ValueError(f"Path component is not a single segment: {component!r}")
|
|
53
|
+
return component
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_artifact_dir(name: str, version: str) -> Path:
|
|
57
|
+
"""Return the directory path for a specific artifact version.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
name: Artifact name (e.g. "chrome", "chromedriver").
|
|
61
|
+
version: Artifact version string.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The path ``{cache_dir}/{name}/{version}/``.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If *name* or *version* contains path separators or
|
|
68
|
+
traversal sequences (``..``).
|
|
69
|
+
"""
|
|
70
|
+
_validate_path_component(name)
|
|
71
|
+
_validate_path_component(version)
|
|
72
|
+
return get_cache_dir() / name / version
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_download_dir() -> Path:
|
|
76
|
+
"""Return the temporary download directory inside the cache.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The path ``{cache_dir}/downloads/``.
|
|
80
|
+
"""
|
|
81
|
+
return get_cache_dir() / "downloads"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def safe_download_path(download_dir: Path, filename: str) -> Path:
|
|
85
|
+
"""Construct a safe download path, rejecting path traversal in *filename*.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
download_dir: The base download directory.
|
|
89
|
+
filename: The filename to append (typically extracted from a URL).
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The resolved path inside *download_dir*.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If *filename* contains path separators or ``..``
|
|
96
|
+
sequences that would escape *download_dir*.
|
|
97
|
+
"""
|
|
98
|
+
_validate_path_component(filename)
|
|
99
|
+
return download_dir / filename
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cleanup_downloads() -> None:
|
|
103
|
+
"""Delete all contents of the download directory.
|
|
104
|
+
|
|
105
|
+
Individual deletion errors are suppressed so that one locked file
|
|
106
|
+
does not prevent cleanup of the remaining items.
|
|
107
|
+
"""
|
|
108
|
+
download_dir = get_download_dir()
|
|
109
|
+
if download_dir.is_symlink():
|
|
110
|
+
download_dir.unlink()
|
|
111
|
+
return
|
|
112
|
+
if not download_dir.exists():
|
|
113
|
+
return
|
|
114
|
+
for item in download_dir.iterdir():
|
|
115
|
+
try:
|
|
116
|
+
if item.is_symlink():
|
|
117
|
+
item.unlink()
|
|
118
|
+
elif item.is_dir():
|
|
119
|
+
safe_rmtree(item)
|
|
120
|
+
else:
|
|
121
|
+
item.unlink()
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_rmtree(path: Path) -> None:
|
|
127
|
+
"""Remove a directory tree, handling symlinks safely.
|
|
128
|
+
|
|
129
|
+
If *path* is a symlink, the symlink itself is unlinked rather than
|
|
130
|
+
recursing into its target. This prevents accidental deletion of
|
|
131
|
+
files outside the cache when ``path`` points elsewhere via a symlink.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
path: Directory path to remove.
|
|
135
|
+
"""
|
|
136
|
+
if path.is_symlink():
|
|
137
|
+
path.unlink()
|
|
138
|
+
return
|
|
139
|
+
shutil.rmtree(path)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_disk_space(required_mb: int) -> bool:
|
|
143
|
+
"""Check if there is enough disk space for an installation.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
required_mb: Required space in megabytes.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if available space is sufficient, False otherwise.
|
|
150
|
+
"""
|
|
151
|
+
cache_dir = get_cache_dir()
|
|
152
|
+
check_dir = cache_dir if cache_dir.exists() else Path.home()
|
|
153
|
+
usage = shutil.disk_usage(check_dir)
|
|
154
|
+
return usage.free >= required_mb * 1024 * 1024
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_available_disk_mb() -> int:
|
|
158
|
+
"""Return available disk space in megabytes.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Available space in MB at the cache directory location.
|
|
162
|
+
"""
|
|
163
|
+
cache_dir = get_cache_dir()
|
|
164
|
+
check_dir = cache_dir if cache_dir.exists() else Path.home()
|
|
165
|
+
usage = shutil.disk_usage(check_dir)
|
|
166
|
+
return usage.free // (1024 * 1024)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_cache_size() -> int:
|
|
170
|
+
"""Return the total size of the cache directory in bytes.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Total bytes in the cache directory (recursive), or 0 if the
|
|
174
|
+
directory does not exist.
|
|
175
|
+
"""
|
|
176
|
+
cache_dir = load_config().cache_dir
|
|
177
|
+
if not cache_dir.exists():
|
|
178
|
+
return 0
|
|
179
|
+
total = 0
|
|
180
|
+
for dirpath, _dirnames, filenames in os.walk(cache_dir, followlinks=False):
|
|
181
|
+
for filename in filenames:
|
|
182
|
+
filepath = Path(dirpath) / filename
|
|
183
|
+
if filepath.is_symlink():
|
|
184
|
+
continue
|
|
185
|
+
with contextlib.suppress(OSError):
|
|
186
|
+
total += filepath.stat().st_size
|
|
187
|
+
return total
|
browserget/checksum.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Checksum computation and verification using hashlib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from browserget.exceptions import ChecksumMismatchError
|
|
10
|
+
|
|
11
|
+
_CHUNK_SIZE = 8192
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_checksum(filepath: Path, algorithm: str) -> str:
|
|
15
|
+
"""Compute the hex digest of a file using the specified algorithm.
|
|
16
|
+
|
|
17
|
+
Reads the file in 8192-byte chunks to handle files larger than memory.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
filepath: Path to the file to hash.
|
|
21
|
+
algorithm: Hash algorithm name (e.g. "sha256", "sha512").
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The hexadecimal digest string.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
FileNotFoundError: If the file does not exist.
|
|
28
|
+
ValueError: If the algorithm is not supported by hashlib.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
hasher = hashlib.new(algorithm)
|
|
32
|
+
except ValueError as exc:
|
|
33
|
+
raise ValueError(f"Unsupported algorithm: {algorithm}") from exc
|
|
34
|
+
|
|
35
|
+
with open(filepath, "rb") as f:
|
|
36
|
+
while True:
|
|
37
|
+
chunk = f.read(_CHUNK_SIZE)
|
|
38
|
+
if not chunk:
|
|
39
|
+
break
|
|
40
|
+
hasher.update(chunk)
|
|
41
|
+
return hasher.hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def verify_checksum(filepath: Path, expected: str, algorithm: str) -> bool:
|
|
45
|
+
"""Verify that a file's checksum matches the expected value.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
filepath: Path to the file to verify.
|
|
49
|
+
expected: Expected hexadecimal digest.
|
|
50
|
+
algorithm: Hash algorithm name (e.g. "sha256", "sha512").
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
True if the computed checksum matches the expected value.
|
|
54
|
+
"""
|
|
55
|
+
actual = compute_checksum(filepath, algorithm)
|
|
56
|
+
return hmac.compare_digest(actual, expected)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def verify_or_raise(filepath: Path, expected: str, algorithm: str) -> None:
|
|
60
|
+
"""Verify a file's checksum, raising on mismatch.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
filepath: Path to the file to verify.
|
|
64
|
+
expected: Expected hexadecimal digest.
|
|
65
|
+
algorithm: Hash algorithm name (e.g. "sha256", "sha512").
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ChecksumMismatchError: If the computed checksum does not match.
|
|
69
|
+
"""
|
|
70
|
+
actual = compute_checksum(filepath, algorithm)
|
|
71
|
+
if not hmac.compare_digest(actual, expected):
|
|
72
|
+
raise ChecksumMismatchError(
|
|
73
|
+
filename=filepath.name,
|
|
74
|
+
expected=expected,
|
|
75
|
+
actual=actual,
|
|
76
|
+
)
|